From 8f1f96ec81df986ff264b42a65014acc6a27a005 Mon Sep 17 00:00:00 2001 From: skyboy Date: Wed, 21 Aug 2024 17:05:45 +0800 Subject: [PATCH] add webdav client --- frontend/src/components/setting/SetSystem.vue | 118 ++++- frontend/src/system/config.ts | 20 +- frontend/src/system/core/FileOs.ts | 2 +- frontend/src/system/index.ts | 13 +- godo/cmd/main.go | 65 ++- godo/libs/dir.go | 3 +- godo/sys/setting.go | 8 +- godo/webdav/auth.go | 409 ++++++++++++++++ godo/webdav/basicAuth.go | 42 ++ godo/webdav/client.go | 438 ++++++++++++++++++ godo/webdav/digestAuth.go | 183 ++++++++ godo/webdav/errors.go | 57 +++ godo/webdav/file.go | 77 +++ godo/webdav/httpclient.go | 229 +++++++++ godo/webdav/netrc.go | 54 +++ godo/webdav/passportAuth.go | 181 ++++++++ godo/webdav/requests.go | 181 ++++++++ godo/webdav/utils.go | 113 +++++ 18 files changed, 2130 insertions(+), 63 deletions(-) create mode 100644 godo/webdav/auth.go create mode 100644 godo/webdav/basicAuth.go create mode 100644 godo/webdav/client.go create mode 100644 godo/webdav/digestAuth.go create mode 100644 godo/webdav/errors.go create mode 100644 godo/webdav/file.go create mode 100644 godo/webdav/httpclient.go create mode 100644 godo/webdav/netrc.go create mode 100644 godo/webdav/passportAuth.go create mode 100644 godo/webdav/requests.go create mode 100644 godo/webdav/utils.go diff --git a/frontend/src/components/setting/SetSystem.vue b/frontend/src/components/setting/SetSystem.vue index 069a18e..51b54db 100644 --- a/frontend/src/components/setting/SetSystem.vue +++ b/frontend/src/components/setting/SetSystem.vue @@ -2,8 +2,12 @@
- +

备份

@@ -49,7 +82,12 @@
导入 - +
@@ -81,9 +119,13 @@ const storeList = [ title: "远程存储", value: "net", }, + { + title: "webdav", + value: "webdav", + }, ]; -const items = ["个人存储","备份还原"]; +const items = ["个人存储", "备份还原"]; const activeIndex = ref(0); @@ -102,6 +144,11 @@ function submitOsInfo() { //name: "osPath", type: saveData.storeType, }; + if (saveData.storeType === "browser") { + setSystemConfig(saveData); + RestartApp(); + return; + } if (saveData.storeType === "local") { if (saveData.storePath === "") { setSystemConfig(saveData); @@ -119,13 +166,7 @@ function submitOsInfo() { .then((res) => { if (res.code === 0) { setSystemConfig(saveData); - Dialog.showMessageBox({ - message: t("save.success"), - title: t("language"), - type: "info", - }).then(() => { - RestartApp(); - }); + RestartApp(); } else { Dialog.showMessageBox({ message: res.message, @@ -143,7 +184,7 @@ function submitOsInfo() { return; } const urlRegex = /^(https?:\/\/)[^\/]+$/; - if(!urlRegex.test(saveData.storenet.url)){ + if (!urlRegex.test(saveData.storenet.url)) { Dialog.showMessageBox({ message: "服务器地址格式错误", type: "error", @@ -151,15 +192,44 @@ function submitOsInfo() { return; } setSystemConfig(saveData); - Dialog.showMessageBox({ - message: t("save.success"), - title: t("language"), - type: "info", - }).then(() => { - RestartApp(); - }); + RestartApp(); + } + if (saveData.storeType === "webdav") { + const urlRegex = /^(https?:\/\/)[^\/]+$/; + if (!urlRegex.test(saveData.webdavClient.url)) { + Dialog.showMessageBox({ + message: "服务器地址格式错误", + type: "error", + }); + return; + } + if (saveData.webdavClient.username === "" || saveData.webdavClient.password === "") { + Dialog.showMessageBox({ + message: "用户名或密码不能为空", + type: "error", + }); + return; + } + postData.name = "webdavClient"; + postData.value = saveData.webdavClient; + const postUrl = config.value.apiUrl + "/system/setting"; + fetch(postUrl, { + method: "POST", + body: JSON.stringify([postData]), + }) + .then((res) => res.json()) + .then((res) => { + if (res.code === 0) { + setSystemConfig(saveData); + RestartApp(); + } else { + Dialog.showMessageBox({ + message: res.message, + type: "error", + }); + } + }); } - } async function exportBackup() { diff --git a/frontend/src/system/config.ts b/frontend/src/system/config.ts index 71ce552..d445ee4 100644 --- a/frontend/src/system/config.ts +++ b/frontend/src/system/config.ts @@ -1,4 +1,3 @@ -import { get } from "http"; import { generateRandomString } from "../util/common.ts" export const configStoreType = localStorage.getItem('GodoOS-storeType') || 'browser'; /** @@ -98,6 +97,13 @@ export const getSystemConfig = (ifset = false) => { password: '', }; } + if (!config.webdavClient) { + config.webdavClient = { + url: '', + username: '', + password: '', + }; + } if (!config.dbInfo) { config.dbInfo = { url: '', @@ -132,13 +138,15 @@ export function getApiUrl() { } export function getFileUrl() { const config = getSystemConfig(); - if(config.storeType == 'local'){ - return config.apiUrl - } if(config.storeType == 'net'){ - return config.storenet.url + return config.storenet.url + '/file' + } + else if (config.storeType == 'webdav') { + return config.apiUrl + '/webdav' + }else{ + return config.apiUrl + '/file' } - return config.apiUrl + } export function isWindowsOS() { diff --git a/frontend/src/system/core/FileOs.ts b/frontend/src/system/core/FileOs.ts index a135c4e..55426b8 100644 --- a/frontend/src/system/core/FileOs.ts +++ b/frontend/src/system/core/FileOs.ts @@ -1,5 +1,5 @@ import { getFileUrl } from "../config.ts"; -const API_BASE_URL = getFileUrl() + "/file" +const API_BASE_URL = getFileUrl() import { OsFileMode } from '../core/FileMode'; export async function handleReadDir(path: string): Promise { const res = await fetch(`${API_BASE_URL}/read?path=${encodeURIComponent(path)}`); diff --git a/frontend/src/system/index.ts b/frontend/src/system/index.ts index 4620f5d..b92d108 100644 --- a/frontend/src/system/index.ts +++ b/frontend/src/system/index.ts @@ -117,11 +117,11 @@ export class System { } const upgradeStore = useUpgradeStore(); upgradeStore.checkUpdate() - setTimeout(() => { - if (this._rootState.magnet?.length < 1) { - this.recover() - } - }, 3000); + // setTimeout(() => { + // if (this._rootState.magnet?.length < 1) { + // this.recover() + // } + // }, 3000); }, 6000); } @@ -247,7 +247,8 @@ export class System { } private async initFileSystem() { - if (getSystemKey("storeType") == 'local'){ + const storeType = getSystemKey("storeType") + if (storeType == 'local' || storeType == 'net'){ //this.fs = this._options.fs; this.fs = useOsFile(); await this.initOutSystem() diff --git a/godo/cmd/main.go b/godo/cmd/main.go index 72583ae..b280ff7 100644 --- a/godo/cmd/main.go +++ b/godo/cmd/main.go @@ -7,6 +7,7 @@ import ( "godo/localchat" "godo/store" "godo/sys" + "godo/webdav" "log" "net/http" "time" @@ -22,7 +23,8 @@ var staticSrv *http.Server var staticRouter *http.ServeMux func OsStart() { - libs.Initdir() + libs.InitServer() + webdav.InitWebdav() router := mux.NewRouter() router.Use(corsMiddleware()) // 使用带有日志装饰的处理器注册路由 @@ -58,28 +60,47 @@ func OsStart() { router.HandleFunc("/system/updateInfo", sys.GetUpdateUrlHandler).Methods(http.MethodGet) router.HandleFunc("/system/update", sys.UpdateAppHandler).Methods(http.MethodGet) router.HandleFunc("/system/setting", sys.ConfigHandler).Methods(http.MethodPost) + fileRouter := router.PathPrefix("/file").Subrouter() + fileRouter.HandleFunc("/info", files.HandleSystemInfo).Methods(http.MethodGet) + fileRouter.HandleFunc("/read", files.HandleReadDir).Methods(http.MethodGet) + fileRouter.HandleFunc("/stat", files.HandleStat).Methods(http.MethodGet) + fileRouter.HandleFunc("/chmod", files.HandleChmod).Methods(http.MethodPost) + fileRouter.HandleFunc("/exists", files.HandleExists).Methods(http.MethodGet) + fileRouter.HandleFunc("/readfile", files.HandleReadFile).Methods(http.MethodGet) + fileRouter.HandleFunc("/unlink", files.HandleUnlink).Methods(http.MethodGet) + fileRouter.HandleFunc("/clear", files.HandleClear).Methods(http.MethodGet) + fileRouter.HandleFunc("/rename", files.HandleRename).Methods(http.MethodGet) + fileRouter.HandleFunc("/mkdir", files.HandleMkdir).Methods(http.MethodPost) + fileRouter.HandleFunc("/rmdir", files.HandleRmdir).Methods(http.MethodGet) + fileRouter.HandleFunc("/copyfile", files.HandleCopyFile).Methods(http.MethodGet) + fileRouter.HandleFunc("/writefile", files.HandleWriteFile).Methods(http.MethodPost) + fileRouter.HandleFunc("/appendfile", files.HandleAppendFile).Methods(http.MethodPost) + fileRouter.HandleFunc("/zip", files.HandleZip).Methods(http.MethodGet) + fileRouter.HandleFunc("/unzip", files.HandleUnZip).Methods(http.MethodGet) + fileRouter.HandleFunc("/watch", files.WatchHandler).Methods(http.MethodGet) + + localchatRouter := router.PathPrefix("/localchat").Subrouter() + localchatRouter.HandleFunc("/sse", localchat.SseHandler).Methods(http.MethodGet) + localchatRouter.HandleFunc("/message", localchat.HandleMessage).Methods(http.MethodPost) + localchatRouter.HandleFunc("/upload", localchat.MultiUploadHandler).Methods(http.MethodPost) + localchatRouter.HandleFunc("/check", localchat.CheckUserHanlder).Methods(http.MethodGet) + + // 注册 WebDAV 路由 + webdavRouter := router.PathPrefix("/webdav").Subrouter() + webdavRouter.HandleFunc("/read", webdav.HandleReadDir).Methods(http.MethodGet) + webdavRouter.HandleFunc("/stat", webdav.HandleStat).Methods(http.MethodGet) + webdavRouter.HandleFunc("/chmod", webdav.HandleChmod).Methods(http.MethodPost) + webdavRouter.HandleFunc("/exists", webdav.HandleExists).Methods(http.MethodGet) + webdavRouter.HandleFunc("/readfile", webdav.HandleReadFile).Methods(http.MethodGet) + webdavRouter.HandleFunc("/unlink", webdav.HandleUnlink).Methods(http.MethodGet) + webdavRouter.HandleFunc("/clear", webdav.HandleClear).Methods(http.MethodGet) + webdavRouter.HandleFunc("/rename", webdav.HandleRename).Methods(http.MethodGet) + webdavRouter.HandleFunc("/mkdir", webdav.HandleMkdir).Methods(http.MethodPost) + webdavRouter.HandleFunc("/rmdir", webdav.HandleRmdir).Methods(http.MethodGet) + webdavRouter.HandleFunc("/copyfile", webdav.HandleCopyFile).Methods(http.MethodGet) + webdavRouter.HandleFunc("/writefile", webdav.HandleWriteFile).Methods(http.MethodPost) + webdavRouter.HandleFunc("/appendfile", webdav.HandleAppendFile).Methods(http.MethodPost) - router.HandleFunc("/file/info", files.HandleSystemInfo).Methods(http.MethodGet) - router.HandleFunc("/file/read", files.HandleReadDir).Methods(http.MethodGet) - router.HandleFunc("/file/stat", files.HandleStat).Methods(http.MethodGet) - router.HandleFunc("/file/chmod", files.HandleChmod).Methods(http.MethodPost) - router.HandleFunc("/file/exists", files.HandleExists).Methods(http.MethodGet) - router.HandleFunc("/file/readfile", files.HandleReadFile).Methods(http.MethodGet) - router.HandleFunc("/file/unlink", files.HandleUnlink).Methods(http.MethodGet) - router.HandleFunc("/file/clear", files.HandleClear).Methods(http.MethodGet) - router.HandleFunc("/file/rename", files.HandleRename).Methods(http.MethodGet) - router.HandleFunc("/file/mkdir", files.HandleMkdir).Methods(http.MethodPost) - router.HandleFunc("/file/rmdir", files.HandleRmdir).Methods(http.MethodGet) - router.HandleFunc("/file/copyfile", files.HandleCopyFile).Methods(http.MethodGet) - router.HandleFunc("/file/writefile", files.HandleWriteFile).Methods(http.MethodPost) - router.HandleFunc("/file/appendfile", files.HandleAppendFile).Methods(http.MethodPost) - router.HandleFunc("/file/zip", files.HandleZip).Methods(http.MethodGet) - router.HandleFunc("/file/unzip", files.HandleUnZip).Methods(http.MethodGet) - router.HandleFunc("/file/watch", files.WatchHandler).Methods(http.MethodGet) - router.HandleFunc("/localchat/sse", localchat.SseHandler).Methods(http.MethodGet) - router.HandleFunc("/localchat/message", localchat.HandleMessage).Methods(http.MethodPost) - router.HandleFunc("/localchat/upload", localchat.MultiUploadHandler).Methods(http.MethodPost) - router.HandleFunc("/localchat/check", localchat.CheckUserHanlder).Methods(http.MethodGet) // 将静态文件服务放在最后,作为默认处理程序 router.PathPrefix("/").Handler(http.NotFoundHandler()) if staticRouter != nil { diff --git a/godo/libs/dir.go b/godo/libs/dir.go index a07db89..4eb89f7 100644 --- a/godo/libs/dir.go +++ b/godo/libs/dir.go @@ -7,7 +7,7 @@ import ( "runtime" ) -func Initdir() error { +func InitServer() error { err := LoadConfig() if err != nil { return err @@ -30,6 +30,7 @@ func Initdir() error { } SetConfig(osInfo) } + return nil } func InitOsDir() (string, error) { diff --git a/godo/sys/setting.go b/godo/sys/setting.go index 52bcbbf..cdf7cd2 100644 --- a/godo/sys/setting.go +++ b/godo/sys/setting.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "godo/libs" + "godo/webdav" "net" "net/http" "os" @@ -33,10 +34,11 @@ func ConfigHandler(w http.ResponseWriter, r *http.Request) { libs.SetConfig(req) } } - if req.Name == "ipList" { - err = SetIplist(req) + if req.Name == "webdavClient" { + libs.SetConfig(req) + err := webdav.InitWebdav() if err != nil { - libs.ErrorMsg(w, err.Error()) + libs.ErrorMsg(w, "The webdav client init is error!") return } } diff --git a/godo/webdav/auth.go b/godo/webdav/auth.go new file mode 100644 index 0000000..16815b4 --- /dev/null +++ b/godo/webdav/auth.go @@ -0,0 +1,409 @@ +package webdav + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "sync" +) + +// AuthFactory prototype function to create a new Authenticator +type AuthFactory func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) + +// Authorizer our Authenticator factory which creates an +// `Authenticator` per action/request. +type Authorizer interface { + // Creates a new Authenticator Shim per request. + // It may track request related states and perform payload buffering + // for authentication round trips. + // The underlying Authenticator will perform the real authentication. + NewAuthenticator(body io.Reader) (Authenticator, io.Reader) + // Registers a new Authenticator factory to a key. + AddAuthenticator(key string, fn AuthFactory) +} + +// An Authenticator implements a specific way to authorize requests. +// Each request is bound to a separate Authenticator instance. +// +// The authentication flow itself is broken down into `Authorize` +// and `Verify` steps. The former method runs before, and the latter +// runs after the `Request` is submitted. +// This makes it easy to encapsulate and control complex +// authentication challenges. +// +// Some authentication flows causing authentication round trips, +// which can be archived by returning the `redo` of the Verify +// method. `True` restarts the authentication process for the +// current action: A new `Request` is spawned, which must be +// authorized, sent, and re-verified again, until the action +// is successfully submitted. +// The preferred way is to handle the authentication ping-pong +// within `Verify`, and then `redo` with fresh credentials. +// +// The result of the `Verify` method can also trigger an +// `Authenticator` change by returning the `ErrAuthChanged` +// as an error. Depending on the `Authorizer` this may trigger +// an `Authenticator` negotiation. +// +// Set the `XInhibitRedirect` header to '1' in the `Authorize` +// method to get control over request redirection. +// Attention! You must handle the incoming request yourself. +// +// To store a shared session state the `Clone` method **must** +// return a new instance, initialized with the shared state. +type Authenticator interface { + // Authorizes a request. Usually by adding some authorization headers. + Authorize(c *http.Client, rq *http.Request, path string) error + // Verifies the response if the authorization was successful. + // May trigger some round trips to pass the authentication. + // May also trigger a new Authenticator negotiation by returning `ErrAuthChenged` + Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) + // Creates a copy of the underlying Authenticator. + Clone() Authenticator + io.Closer +} + +type authfactory struct { + key string + create AuthFactory +} + +// authorizer structure holds our Authenticator create functions +type authorizer struct { + factories []authfactory + defAuthMux sync.Mutex + defAuth Authenticator +} + +// preemptiveAuthorizer structure holds the preemptive Authenticator +type preemptiveAuthorizer struct { + auth Authenticator +} + +// authShim structure that wraps the real Authenticator +type authShim struct { + factory AuthFactory + body io.Reader + auth Authenticator +} + +// negoAuth structure holds the authenticators that are going to be negotiated +type negoAuth struct { + auths []Authenticator + setDefaultAuthenticator func(auth Authenticator) +} + +// nullAuth initializes the whole authentication flow +type nullAuth struct{} + +// noAuth structure to perform no authentication at all +type noAuth struct{} + +// NewAutoAuth creates an auto Authenticator factory. +// It negotiates the default authentication method +// based on the order of the registered Authenticators +// and the remotely offered authentication methods. +// First In, First Out. +func NewAutoAuth(login string, secret string) Authorizer { + fmap := make([]authfactory, 0) + az := &authorizer{factories: fmap, defAuthMux: sync.Mutex{}, defAuth: &nullAuth{}} + + az.AddAuthenticator("basic", func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) { + return &BasicAuth{user: login, pw: secret}, nil + }) + + az.AddAuthenticator("digest", func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) { + return NewDigestAuth(login, secret, rs) + }) + + az.AddAuthenticator("passport1.4", func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) { + return NewPassportAuth(c, login, secret, rs.Request.URL.String(), &rs.Header) + }) + + return az +} + +// NewEmptyAuth creates an empty Authenticator factory +// The order of adding the Authenticator matters. +// First In, First Out. +// It offers the `NewAutoAuth` features. +func NewEmptyAuth() Authorizer { + fmap := make([]authfactory, 0) + az := &authorizer{factories: fmap, defAuthMux: sync.Mutex{}, defAuth: &nullAuth{}} + return az +} + +// NewPreemptiveAuth creates a preemptive Authenticator +// The preemptive authorizer uses the provided Authenticator +// for every request regardless of any `Www-Authenticate` header. +// +// It may only have one authentication method, +// so calling `AddAuthenticator` **will panic**! +// +// Look out!! This offers the skinniest and slickest implementation +// without any synchronisation!! +// Still applicable with `BasicAuth` within go routines. +func NewPreemptiveAuth(auth Authenticator) Authorizer { + return &preemptiveAuthorizer{auth: auth} +} + +// NewAuthenticator creates an Authenticator (Shim) per request +func (a *authorizer) NewAuthenticator(body io.Reader) (Authenticator, io.Reader) { + var retryBuf io.Reader = body + if body != nil { + // If the authorization fails, we will need to restart reading + // from the passed body stream. + // When body is seekable, use seek to reset the streams + // cursor to the start. + // Otherwise, copy the stream into a buffer while uploading + // and use the buffers content on retry. + if _, ok := retryBuf.(io.Seeker); ok { + body = io.NopCloser(body) + } else { + buff := &bytes.Buffer{} + retryBuf = buff + body = io.TeeReader(body, buff) + } + } + a.defAuthMux.Lock() + defAuth := a.defAuth.Clone() + a.defAuthMux.Unlock() + + return &authShim{factory: a.factory, body: retryBuf, auth: defAuth}, body +} + +// AddAuthenticator appends the AuthFactory to our factories. +// It converts the key to lower case and preserves the order. +func (a *authorizer) AddAuthenticator(key string, fn AuthFactory) { + key = strings.ToLower(key) + for _, f := range a.factories { + if f.key == key { + panic("Authenticator exists: " + key) + } + } + a.factories = append(a.factories, authfactory{key, fn}) +} + +// factory picks all valid Authenticators based on Www-Authenticate headers +func (a *authorizer) factory(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) { + headers := rs.Header.Values("Www-Authenticate") + if len(headers) > 0 { + auths := make([]Authenticator, 0) + for _, f := range a.factories { + for _, header := range headers { + headerLower := strings.ToLower(header) + if strings.Contains(headerLower, f.key) { + rs.Header.Set("Www-Authenticate", header) + if auth, err = f.create(c, rs, path); err == nil { + auths = append(auths, auth) + break + } + } + } + } + + switch len(auths) { + case 0: + return nil, NewPathError("NoAuthenticator", path, rs.StatusCode) + case 1: + auth = auths[0] + default: + auth = &negoAuth{auths: auths, setDefaultAuthenticator: a.setDefaultAuthenticator} + } + } else { + auth = &noAuth{} + } + + a.setDefaultAuthenticator(auth) + + return auth, nil +} + +// setDefaultAuthenticator sets the default Authenticator +func (a *authorizer) setDefaultAuthenticator(auth Authenticator) { + a.defAuthMux.Lock() + a.defAuth.Close() + a.defAuth = auth + a.defAuthMux.Unlock() +} + +// Authorize the current request +func (s *authShim) Authorize(c *http.Client, rq *http.Request, path string) error { + if err := s.auth.Authorize(c, rq, path); err != nil { + return err + } + body := s.body + rq.GetBody = func() (io.ReadCloser, error) { + if body != nil { + if sk, ok := body.(io.Seeker); ok { + if _, err := sk.Seek(0, io.SeekStart); err != nil { + return nil, err + } + } + return io.NopCloser(body), nil + } + return nil, nil + } + return nil +} + +// Verify checks for authentication issues and may trigger a re-authentication. +// Catches AlgoChangedErr to update the current Authenticator +func (s *authShim) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + redo, err = s.auth.Verify(c, rs, path) + if err != nil && errors.Is(err, ErrAuthChanged) { + if auth, aerr := s.factory(c, rs, path); aerr == nil { + s.auth.Close() + s.auth = auth + return true, nil + } else { + return false, aerr + } + } + return +} + +// Close closes all resources +func (s *authShim) Close() error { + s.auth.Close() + s.auth, s.factory = nil, nil + if s.body != nil { + if closer, ok := s.body.(io.Closer); ok { + return closer.Close() + } + } + return nil +} + +// It's not intend to Clone the shim +// therefore it returns a noAuth instance +func (s *authShim) Clone() Authenticator { + return &noAuth{} +} + +// String toString +func (s *authShim) String() string { + return "AuthShim" +} + +// Authorize authorizes the current request with the top most Authorizer +func (n *negoAuth) Authorize(c *http.Client, rq *http.Request, path string) error { + if len(n.auths) == 0 { + return NewPathError("NoAuthenticator", path, 400) + } + return n.auths[0].Authorize(c, rq, path) +} + +// Verify verifies the authentication and selects the next one based on the result +func (n *negoAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + if len(n.auths) == 0 { + return false, NewPathError("NoAuthenticator", path, 400) + } + redo, err = n.auths[0].Verify(c, rs, path) + if err != nil { + if len(n.auths) > 1 { + n.auths[0].Close() + n.auths = n.auths[1:] + return true, nil + } + } else if redo { + return + } else { + auth := n.auths[0] + n.auths = n.auths[1:] + n.setDefaultAuthenticator(auth) + return + } + + return false, NewPathError("NoAuthenticator", path, rs.StatusCode) +} + +// Close will close the underlying authenticators. +func (n *negoAuth) Close() error { + for _, a := range n.auths { + a.Close() + } + n.setDefaultAuthenticator = nil + return nil +} + +// Clone clones the underlying authenticators. +func (n *negoAuth) Clone() Authenticator { + auths := make([]Authenticator, len(n.auths)) + for i, e := range n.auths { + auths[i] = e.Clone() + } + return &negoAuth{auths: auths, setDefaultAuthenticator: n.setDefaultAuthenticator} +} + +func (n *negoAuth) String() string { + return "NegoAuth" +} + +// Authorize the current request +func (n *noAuth) Authorize(c *http.Client, rq *http.Request, path string) error { + return nil +} + +// Verify checks for authentication issues and may trigger a re-authentication +func (n *noAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + if "" != rs.Header.Get("Www-Authenticate") { + err = ErrAuthChanged + } + return +} + +// Close closes all resources +func (n *noAuth) Close() error { + return nil +} + +// Clone creates a copy of itself +func (n *noAuth) Clone() Authenticator { + // no copy due to read only access + return n +} + +// String toString +func (n *noAuth) String() string { + return "NoAuth" +} + +// Authorize the current request +func (n *nullAuth) Authorize(c *http.Client, rq *http.Request, path string) error { + rq.Header.Set(XInhibitRedirect, "1") + return nil +} + +// Verify checks for authentication issues and may trigger a re-authentication +func (n *nullAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + return true, ErrAuthChanged +} + +// Close closes all resources +func (n *nullAuth) Close() error { + return nil +} + +// Clone creates a copy of itself +func (n *nullAuth) Clone() Authenticator { + // no copy due to read only access + return n +} + +// String toString +func (n *nullAuth) String() string { + return "NullAuth" +} + +// NewAuthenticator creates an Authenticator (Shim) per request +func (b *preemptiveAuthorizer) NewAuthenticator(body io.Reader) (Authenticator, io.Reader) { + return b.auth.Clone(), body +} + +// AddAuthenticator Will PANIC because it may only have a single authentication method +func (b *preemptiveAuthorizer) AddAuthenticator(key string, fn AuthFactory) { + panic("You're funny! A preemptive authorizer may only have a single authentication method") +} diff --git a/godo/webdav/basicAuth.go b/godo/webdav/basicAuth.go new file mode 100644 index 0000000..fd8b4ea --- /dev/null +++ b/godo/webdav/basicAuth.go @@ -0,0 +1,42 @@ +package webdav + +import ( + "fmt" + "net/http" +) + +// BasicAuth structure holds our credentials +type BasicAuth struct { + user string + pw string +} + +// Authorize the current request +func (b *BasicAuth) Authorize(c *http.Client, rq *http.Request, path string) error { + rq.SetBasicAuth(b.user, b.pw) + return nil +} + +// Verify verifies if the authentication +func (b *BasicAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + if rs.StatusCode == 401 { + err = NewPathError("Authorize", path, rs.StatusCode) + } + return +} + +// Close cleans up all resources +func (b *BasicAuth) Close() error { + return nil +} + +// Clone creates a Copy of itself +func (b *BasicAuth) Clone() Authenticator { + // no copy due to read only access + return b +} + +// String toString +func (b *BasicAuth) String() string { + return fmt.Sprintf("BasicAuth login: %s", b.user) +} diff --git a/godo/webdav/client.go b/godo/webdav/client.go new file mode 100644 index 0000000..1dc87fb --- /dev/null +++ b/godo/webdav/client.go @@ -0,0 +1,438 @@ +package webdav + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "os" + pathpkg "path" + "strings" + "time" +) + +const XInhibitRedirect = "X-Gowebdav-Inhibit-Redirect" + +// Client defines our structure +type Client struct { + root string + headers http.Header + interceptor func(method string, rq *http.Request) + c *http.Client + auth Authorizer +} + +// NewClient creates a new instance of client +func NewClient(uri, user, pw string) *Client { + return NewAuthClient(uri, NewAutoAuth(user, pw)) +} + +// NewAuthClient creates a new client instance with a custom Authorizer +func NewAuthClient(uri string, auth Authorizer) *Client { + c := &http.Client{ + CheckRedirect: func(rq *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return ErrTooManyRedirects + } + if via[0].Header.Get(XInhibitRedirect) != "" { + return http.ErrUseLastResponse + } + return nil + }, + } + return &Client{root: FixSlash(uri), headers: make(http.Header), interceptor: nil, c: c, auth: auth} +} + +// SetHeader lets us set arbitrary headers for a given client +func (c *Client) SetHeader(key, value string) { + c.headers.Add(key, value) +} + +// SetInterceptor lets us set an arbitrary interceptor for a given client +func (c *Client) SetInterceptor(interceptor func(method string, rq *http.Request)) { + c.interceptor = interceptor +} + +// SetTimeout exposes the ability to set a time limit for requests +func (c *Client) SetTimeout(timeout time.Duration) { + c.c.Timeout = timeout +} + +// SetTransport exposes the ability to define custom transports +func (c *Client) SetTransport(transport http.RoundTripper) { + c.c.Transport = transport +} + +// SetJar exposes the ability to set a cookie jar to the client. +func (c *Client) SetJar(jar http.CookieJar) { + c.c.Jar = jar +} + +// Connect connects to our dav server +func (c *Client) Connect() error { + rs, err := c.options("/") + if err != nil { + return err + } + + err = rs.Body.Close() + if err != nil { + return err + } + + if rs.StatusCode != 200 { + return NewPathError("Connect", c.root, rs.StatusCode) + } + + return nil +} + +type props struct { + Status string `xml:"DAV: status"` + Name string `xml:"DAV: prop>displayname,omitempty"` + Type xml.Name `xml:"DAV: prop>resourcetype>collection,omitempty"` + Size string `xml:"DAV: prop>getcontentlength,omitempty"` + ContentType string `xml:"DAV: prop>getcontenttype,omitempty"` + ETag string `xml:"DAV: prop>getetag,omitempty"` + Modified string `xml:"DAV: prop>getlastmodified,omitempty"` +} + +type response struct { + Href string `xml:"DAV: href"` + Props []props `xml:"DAV: propstat"` +} + +func getProps(r *response, status string) *props { + for _, prop := range r.Props { + if strings.Contains(prop.Status, status) { + return &prop + } + } + return nil +} + +// ReadDir reads the contents of a remote directory +func (c *Client) ReadDir(path string) ([]os.FileInfo, error) { + path = FixSlashes(path) + files := make([]os.FileInfo, 0) + skipSelf := true + parse := func(resp interface{}) error { + r := resp.(*response) + + if skipSelf { + skipSelf = false + if p := getProps(r, "200"); p != nil && p.Type.Local == "collection" { + r.Props = nil + return nil + } + return NewPathError("ReadDir", path, 405) + } + + if p := getProps(r, "200"); p != nil { + f := new(File) + if ps, err := url.PathUnescape(r.Href); err == nil { + f.name = pathpkg.Base(ps) + } else { + f.name = p.Name + } + f.path = path + f.name + f.modified = parseModified(&p.Modified) + f.etag = p.ETag + f.contentType = p.ContentType + + if p.Type.Local == "collection" { + f.path += "/" + f.size = 0 + f.isdir = true + } else { + f.size = parseInt64(&p.Size) + f.isdir = false + } + + files = append(files, *f) + } + + r.Props = nil + return nil + } + + err := c.propfind(path, false, + ` + + + + + + + + + `, + &response{}, + parse) + + if err != nil { + if _, ok := err.(*os.PathError); !ok { + err = NewPathErrorErr("ReadDir", path, err) + } + } + return files, err +} + +// Stat returns the file stats for a specified path +func (c *Client) Stat(path string) (os.FileInfo, error) { + var f *File + parse := func(resp interface{}) error { + r := resp.(*response) + if p := getProps(r, "200"); p != nil && f == nil { + f = new(File) + f.name = p.Name + f.path = path + f.etag = p.ETag + f.contentType = p.ContentType + + if p.Type.Local == "collection" { + if !strings.HasSuffix(f.path, "/") { + f.path += "/" + } + f.size = 0 + f.modified = parseModified(&p.Modified) + f.isdir = true + } else { + f.size = parseInt64(&p.Size) + f.modified = parseModified(&p.Modified) + f.isdir = false + } + } + + r.Props = nil + return nil + } + + err := c.propfind(path, true, + ` + + + + + + + + + `, + &response{}, + parse) + + if err != nil { + if _, ok := err.(*os.PathError); !ok { + err = NewPathErrorErr("ReadDir", path, err) + } + } + return f, err +} + +// Remove removes a remote file +func (c *Client) Remove(path string) error { + return c.RemoveAll(path) +} + +// RemoveAll removes remote files +func (c *Client) RemoveAll(path string) error { + rs, err := c.req("DELETE", path, nil, nil) + if err != nil { + return NewPathError("Remove", path, 400) + } + err = rs.Body.Close() + if err != nil { + return err + } + + if rs.StatusCode == 200 || rs.StatusCode == 204 || rs.StatusCode == 404 { + return nil + } + + return NewPathError("Remove", path, rs.StatusCode) +} + +// Mkdir makes a directory +func (c *Client) Mkdir(path string, _ os.FileMode) (err error) { + path = FixSlashes(path) + status, err := c.mkcol(path) + if err != nil { + return + } + if status == 201 { + return nil + } + + return NewPathError("Mkdir", path, status) +} + +// MkdirAll like mkdir -p, but for webdav +func (c *Client) MkdirAll(path string, _ os.FileMode) (err error) { + path = FixSlashes(path) + status, err := c.mkcol(path) + if err != nil { + return + } + if status == 201 { + return nil + } + if status == 409 { + paths := strings.Split(path, "/") + sub := "/" + for _, e := range paths { + if e == "" { + continue + } + sub += e + "/" + status, err = c.mkcol(sub) + if err != nil { + return + } + if status != 201 { + return NewPathError("MkdirAll", sub, status) + } + } + return nil + } + + return NewPathError("MkdirAll", path, status) +} + +// Rename moves a file from A to B +func (c *Client) Rename(oldpath, newpath string, overwrite bool) error { + return c.copymove("MOVE", oldpath, newpath, overwrite) +} + +// Copy copies a file from A to B +func (c *Client) Copy(oldpath, newpath string, overwrite bool) error { + return c.copymove("COPY", oldpath, newpath, overwrite) +} + +// Read reads the contents of a remote file +func (c *Client) Read(path string) ([]byte, error) { + var stream io.ReadCloser + var err error + + if stream, err = c.ReadStream(path); err != nil { + return nil, err + } + defer stream.Close() + + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(stream) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// ReadStream reads the stream for a given path +func (c *Client) ReadStream(path string) (io.ReadCloser, error) { + rs, err := c.req("GET", path, nil, nil) + if err != nil { + return nil, NewPathErrorErr("ReadStream", path, err) + } + + if rs.StatusCode == 200 { + return rs.Body, nil + } + + rs.Body.Close() + return nil, NewPathError("ReadStream", path, rs.StatusCode) +} + +// ReadStreamRange reads the stream representing a subset of bytes for a given path, +// utilizing HTTP Range Requests if the server supports it. +// The range is expressed as offset from the start of the file and length, for example +// offset=10, length=10 will return bytes 10 through 19. +// +// If the server does not support partial content requests and returns full content instead, +// this function will emulate the behavior by skipping `offset` bytes and limiting the result +// to `length`. +func (c *Client) ReadStreamRange(path string, offset, length int64) (io.ReadCloser, error) { + rs, err := c.req("GET", path, nil, func(r *http.Request) { + if length > 0 { + r.Header.Add("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+length-1)) + } else { + r.Header.Add("Range", fmt.Sprintf("bytes=%d-", offset)) + } + }) + if err != nil { + return nil, NewPathErrorErr("ReadStreamRange", path, err) + } + + if rs.StatusCode == http.StatusPartialContent { + // server supported partial content, return as-is. + return rs.Body, nil + } + + // server returned success, but did not support partial content, so we have the whole + // stream in rs.Body + if rs.StatusCode == 200 { + // discard first 'offset' bytes. + if _, err := io.Copy(io.Discard, io.LimitReader(rs.Body, offset)); err != nil { + return nil, NewPathErrorErr("ReadStreamRange", path, err) + } + + // return a io.ReadCloser that is limited to `length` bytes. + return &limitedReadCloser{rc: rs.Body, remaining: int(length)}, nil + } + + rs.Body.Close() + return nil, NewPathError("ReadStream", path, rs.StatusCode) +} + +// Write writes data to a given path +func (c *Client) Write(path string, data []byte, _ os.FileMode) (err error) { + s, err := c.put(path, bytes.NewReader(data)) + if err != nil { + return + } + + switch s { + + case 200, 201, 204: + return nil + + case 404, 409: + err = c.createParentCollection(path) + if err != nil { + return + } + + s, err = c.put(path, bytes.NewReader(data)) + if err != nil { + return + } + if s == 200 || s == 201 || s == 204 { + return + } + } + + return NewPathError("Write", path, s) +} + +// WriteStream writes a stream +func (c *Client) WriteStream(path string, stream io.Reader, _ os.FileMode) (err error) { + + err = c.createParentCollection(path) + if err != nil { + return err + } + + s, err := c.put(path, stream) + if err != nil { + return err + } + + switch s { + case 200, 201, 204: + return nil + + default: + return NewPathError("WriteStream", path, s) + } +} diff --git a/godo/webdav/digestAuth.go b/godo/webdav/digestAuth.go new file mode 100644 index 0000000..fbcfc29 --- /dev/null +++ b/godo/webdav/digestAuth.go @@ -0,0 +1,183 @@ +package webdav + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" +) + +// DigestAuth structure holds our credentials +type DigestAuth struct { + user string + pw string + digestParts map[string]string +} + +// NewDigestAuth creates a new instance of our Digest Authenticator +func NewDigestAuth(login, secret string, rs *http.Response) (Authenticator, error) { + return &DigestAuth{user: login, pw: secret, digestParts: digestParts(rs)}, nil +} + +// Authorize the current request +func (d *DigestAuth) Authorize(c *http.Client, rq *http.Request, path string) error { + d.digestParts["uri"] = path + d.digestParts["method"] = rq.Method + d.digestParts["username"] = d.user + d.digestParts["password"] = d.pw + rq.Header.Set("Authorization", getDigestAuthorization(d.digestParts)) + return nil +} + +// Verify checks for authentication issues and may trigger a re-authentication +func (d *DigestAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + if rs.StatusCode == 401 { + if isStaled(rs) { + redo = true + err = ErrAuthChanged + } else { + err = NewPathError("Authorize", path, rs.StatusCode) + } + } + return +} + +// Close cleans up all resources +func (d *DigestAuth) Close() error { + return nil +} + +// Clone creates a copy of itself +func (d *DigestAuth) Clone() Authenticator { + parts := make(map[string]string, len(d.digestParts)) + for k, v := range d.digestParts { + parts[k] = v + } + return &DigestAuth{user: d.user, pw: d.pw, digestParts: parts} +} + +// String toString +func (d *DigestAuth) String() string { + return fmt.Sprintf("DigestAuth login: %s", d.user) +} + +func digestParts(resp *http.Response) map[string]string { + result := map[string]string{} + if len(resp.Header["Www-Authenticate"]) > 0 { + wantedHeaders := []string{"nonce", "realm", "qop", "opaque", "algorithm", "entityBody"} + responseHeaders := strings.Split(resp.Header["Www-Authenticate"][0], ",") + for _, r := range responseHeaders { + for _, w := range wantedHeaders { + if strings.Contains(r, w) { + result[w] = strings.Trim( + strings.SplitN(r, `=`, 2)[1], + `"`, + ) + } + } + } + } + return result +} + +func getMD5(text string) string { + hasher := md5.New() + hasher.Write([]byte(text)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +func getCnonce() string { + b := make([]byte, 8) + io.ReadFull(rand.Reader, b) + return fmt.Sprintf("%x", b)[:16] +} + +func getDigestAuthorization(digestParts map[string]string) string { + d := digestParts + // These are the correct ha1 and ha2 for qop=auth. We should probably check for other types of qop. + + var ( + ha1 string + ha2 string + nonceCount = 00000001 + cnonce = getCnonce() + response string + ) + + // 'ha1' value depends on value of "algorithm" field + switch d["algorithm"] { + case "MD5", "": + ha1 = getMD5(d["username"] + ":" + d["realm"] + ":" + d["password"]) + case "MD5-sess": + ha1 = getMD5( + fmt.Sprintf("%s:%v:%s", + getMD5(d["username"]+":"+d["realm"]+":"+d["password"]), + nonceCount, + cnonce, + ), + ) + } + + // 'ha2' value depends on value of "qop" field + switch d["qop"] { + case "auth", "": + ha2 = getMD5(d["method"] + ":" + d["uri"]) + case "auth-int": + if d["entityBody"] != "" { + ha2 = getMD5(d["method"] + ":" + d["uri"] + ":" + getMD5(d["entityBody"])) + } + } + + // 'response' value depends on value of "qop" field + switch d["qop"] { + case "": + response = getMD5( + fmt.Sprintf("%s:%s:%s", + ha1, + d["nonce"], + ha2, + ), + ) + case "auth", "auth-int": + response = getMD5( + fmt.Sprintf("%s:%s:%v:%s:%s:%s", + ha1, + d["nonce"], + nonceCount, + cnonce, + d["qop"], + ha2, + ), + ) + } + + authorization := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", nc=%v, cnonce="%s", response="%s"`, + d["username"], d["realm"], d["nonce"], d["uri"], nonceCount, cnonce, response) + + if d["qop"] != "" { + authorization += fmt.Sprintf(`, qop=%s`, d["qop"]) + } + + if d["opaque"] != "" { + authorization += fmt.Sprintf(`, opaque="%s"`, d["opaque"]) + } + + return authorization +} + +func isStaled(rs *http.Response) bool { + header := rs.Header.Get("Www-Authenticate") + if len(header) > 0 { + directives := strings.Split(header, ",") + for i := range directives { + name, value, _ := strings.Cut(strings.Trim(directives[i], " "), "=") + if strings.EqualFold(name, "stale") { + return strings.EqualFold(value, "true") + } + } + } + return false +} diff --git a/godo/webdav/errors.go b/godo/webdav/errors.go new file mode 100644 index 0000000..8a78232 --- /dev/null +++ b/godo/webdav/errors.go @@ -0,0 +1,57 @@ +package webdav + +import ( + "errors" + "fmt" + "os" +) + +// ErrAuthChanged must be returned from the Verify method as an error +// to trigger a re-authentication / negotiation with a new authenticator. +var ErrAuthChanged = errors.New("authentication failed, change algorithm") + +// ErrTooManyRedirects will be used as return error if a request exceeds 10 redirects. +var ErrTooManyRedirects = errors.New("stopped after 10 redirects") + +// StatusError implements error and wraps +// an erroneous status code. +type StatusError struct { + Status int +} + +func (se StatusError) Error() string { + return fmt.Sprintf("%d", se.Status) +} + +// IsErrCode returns true if the given error +// is an os.PathError wrapping a StatusError +// with the given status code. +func IsErrCode(err error, code int) bool { + if pe, ok := err.(*os.PathError); ok { + se, ok := pe.Err.(StatusError) + return ok && se.Status == code + } + return false +} + +// IsErrNotFound is shorthand for IsErrCode +// for status 404. +func IsErrNotFound(err error) bool { + return IsErrCode(err, 404) +} + +func NewPathError(op string, path string, statusCode int) error { + return &os.PathError{ + Op: op, + Path: path, + Err: StatusError{statusCode}, + } +} + +func NewPathErrorErr(op string, path string, err error) error { + return &os.PathError{ + Op: op, + Path: path, + Err: err, + } +} diff --git a/godo/webdav/file.go b/godo/webdav/file.go new file mode 100644 index 0000000..77880dc --- /dev/null +++ b/godo/webdav/file.go @@ -0,0 +1,77 @@ +package webdav + +import ( + "fmt" + "os" + "time" +) + +// File is our structure for a given file +type File struct { + path string + name string + contentType string + size int64 + modified time.Time + etag string + isdir bool +} + +// Path returns the full path of a file +func (f File) Path() string { + return f.path +} + +// Name returns the name of a file +func (f File) Name() string { + return f.name +} + +// ContentType returns the content type of a file +func (f File) ContentType() string { + return f.contentType +} + +// Size returns the size of a file +func (f File) Size() int64 { + return f.size +} + +// Mode will return the mode of a given file +func (f File) Mode() os.FileMode { + // TODO check webdav perms + if f.isdir { + return 0775 | os.ModeDir + } + + return 0664 +} + +// ModTime returns the modified time of a file +func (f File) ModTime() time.Time { + return f.modified +} + +// ETag returns the ETag of a file +func (f File) ETag() string { + return f.etag +} + +// IsDir let us see if a given file is a directory or not +func (f File) IsDir() bool { + return f.isdir +} + +// Sys ???? +func (f File) Sys() interface{} { + return nil +} + +// String lets us see file information +func (f File) String() string { + if f.isdir { + return fmt.Sprintf("Dir : '%s' - '%s'", f.path, f.name) + } + + return fmt.Sprintf("File: '%s' SIZE: %d MODIFIED: %s ETAG: %s CTYPE: %s", f.path, f.size, f.modified.String(), f.etag, f.contentType) +} diff --git a/godo/webdav/httpclient.go b/godo/webdav/httpclient.go new file mode 100644 index 0000000..f52909a --- /dev/null +++ b/godo/webdav/httpclient.go @@ -0,0 +1,229 @@ +package webdav + +import ( + "fmt" + "godo/libs" + "io" + "net/http" + + "github.com/gorilla/mux" +) + +var client *Client + +func InitWebdav() error { + clientInfo, ok := libs.GetConfig("webdavClient") + if !ok { + return fmt.Errorf("failed to find configuration for webdavClient") + } + + infoMap, ok := clientInfo.(map[string]string) + if !ok { + return fmt.Errorf("configuration for webdavClient is not a map[string]string") + } + + url, ok := infoMap["url"] + if !ok { + return fmt.Errorf("missing 'url' in webdavClient configuration") + } + + username, ok := infoMap["username"] + if !ok { + return fmt.Errorf("missing 'username' in webdavClient configuration") + } + + password, ok := infoMap["password"] + if !ok { + return fmt.Errorf("missing 'password' in webdavClient configuration") + } + + client = NewClient(url, username, password) + + if err := client.Connect(); err != nil { + return fmt.Errorf("failed to connect to WebDAV server: %v", err) + } + return nil +} +func HandlePing(w http.ResponseWriter, r *http.Request) { + err := client.Connect() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.WriteHeader(http.StatusOK) +} + +// HandleReadDir: 读取目录内容 +func HandleReadDir(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + files, err := client.ReadDir(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, f := range files { + w.Write([]byte(f.Name() + "\n")) + } +} + +// HandleStat: 获取文件状态 +func HandleStat(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + fi, err := client.Stat(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fileInfo, ok := fi.(*File) + if !ok { + http.Error(w, "Unexpected file info type", http.StatusInternalServerError) + return + } + // 直接调用 fi.String(),因为 fi 应该是 *File 类型 + w.Write([]byte(fileInfo.String())) +} + +// HandleChmod: 改变文件权限 (不支持) +func HandleChmod(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Not supported", http.StatusNotImplemented) +} + +// HandleExists: 检查文件是否存在 +func HandleExists(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + _, err := client.Stat(path) + if err != nil { + http.Error(w, "File does not exist", http.StatusNotFound) + return + } + w.Write([]byte("File exists")) +} + +// HandleReadFile: 读取文件内容 +func HandleReadFile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + data, err := client.Read(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(data) +} + +// HandleUnlink: 删除文件 +func HandleUnlink(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + err := client.Remove(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("File deleted")) +} + +// HandleClear: 清空目录 (不支持) +func HandleClear(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Not supported", http.StatusNotImplemented) +} + +// HandleRename: 重命名文件或目录 +func HandleRename(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + oldPath := vars["oldPath"] + newPath := vars["newPath"] + err := client.Rename(oldPath, newPath, false) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("File renamed")) +} + +// HandleMkdir: 创建目录 +func HandleMkdir(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + err := client.Mkdir(path, 0) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("Directory created")) +} + +// HandleRmdir: 删除目录 +func HandleRmdir(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + err := client.RemoveAll(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("Directory removed")) +} + +// HandleCopyFile: 复制文件 +func HandleCopyFile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + oldPath := vars["oldPath"] + newPath := vars["newPath"] + err := client.Copy(oldPath, newPath, false) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("File copied")) +} + +// HandleWriteFile: 写入文件 +func HandleWriteFile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = client.Write(path, body, 0) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("File written")) +} + +// HandleAppendFile: 追加到文件 +func HandleAppendFile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 读取现有文件内容 + existingContent, err := client.Read(path) + if err != nil && !IsErrNotFound(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 如果文件不存在,则创建新文件 + if IsErrNotFound(err) { + err = client.Write(path, body, 0) + } else { + // 如果文件存在,则追加内容 + err = client.Write(path, append(existingContent, body...), 0) + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte("File appended")) +} diff --git a/godo/webdav/netrc.go b/godo/webdav/netrc.go new file mode 100644 index 0000000..dc18f56 --- /dev/null +++ b/godo/webdav/netrc.go @@ -0,0 +1,54 @@ +package webdav + +import ( + "bufio" + "fmt" + "net/url" + "os" + "regexp" + "strings" +) + +func parseLine(s string) (login, pass string) { + fields := strings.Fields(s) + for i, f := range fields { + if f == "login" { + login = fields[i+1] + } + if f == "password" { + pass = fields[i+1] + } + } + return login, pass +} + +// ReadConfig reads login and password configuration from ~/.netrc +// machine foo.com login username password 123456 +func ReadConfig(uri, netrc string) (string, string) { + u, err := url.Parse(uri) + if err != nil { + return "", "" + } + + file, err := os.Open(netrc) + if err != nil { + return "", "" + } + defer file.Close() + + re := fmt.Sprintf(`^.*machine %s.*$`, u.Host) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + s := scanner.Text() + + matched, err := regexp.MatchString(re, s) + if err != nil { + return "", "" + } + if matched { + return parseLine(s) + } + } + + return "", "" +} diff --git a/godo/webdav/passportAuth.go b/godo/webdav/passportAuth.go new file mode 100644 index 0000000..a2d7436 --- /dev/null +++ b/godo/webdav/passportAuth.go @@ -0,0 +1,181 @@ +package webdav + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// PassportAuth structure holds our credentials +type PassportAuth struct { + user string + pw string + cookies []http.Cookie + inhibitRedirect bool +} + +// constructor for PassportAuth creates a new PassportAuth object and +// automatically authenticates against the given partnerURL +func NewPassportAuth(c *http.Client, user, pw, partnerURL string, header *http.Header) (Authenticator, error) { + p := &PassportAuth{ + user: user, + pw: pw, + inhibitRedirect: true, + } + err := p.genCookies(c, partnerURL, header) + return p, err +} + +// Authorize the current request +func (p *PassportAuth) Authorize(c *http.Client, rq *http.Request, path string) error { + // prevent redirects to detect subsequent authentication requests + if p.inhibitRedirect { + rq.Header.Set(XInhibitRedirect, "1") + } else { + p.inhibitRedirect = true + } + for _, cookie := range p.cookies { + rq.AddCookie(&cookie) + } + return nil +} + +// Verify verifies if the authentication is good +func (p *PassportAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { + switch rs.StatusCode { + case 301, 302, 307, 308: + redo = true + if rs.Header.Get("Www-Authenticate") != "" { + // re-authentication required as we are redirected to the login page + err = p.genCookies(c, rs.Request.URL.String(), &rs.Header) + } else { + // just a redirect, follow it + p.inhibitRedirect = false + } + case 401: + err = NewPathError("Authorize", path, rs.StatusCode) + } + return +} + +// Close cleans up all resources +func (p *PassportAuth) Close() error { + return nil +} + +// Clone creates a Copy of itself +func (p *PassportAuth) Clone() Authenticator { + // create a copy to allow independent cookie updates + clonedCookies := make([]http.Cookie, len(p.cookies)) + copy(clonedCookies, p.cookies) + + return &PassportAuth{ + user: p.user, + pw: p.pw, + cookies: clonedCookies, + inhibitRedirect: true, + } +} + +// String toString +func (p *PassportAuth) String() string { + return fmt.Sprintf("PassportAuth login: %s", p.user) +} + +func (p *PassportAuth) genCookies(c *http.Client, partnerUrl string, header *http.Header) error { + // For more details refer to: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pass/2c80637d-438c-4d4b-adc5-903170a779f3 + // Skipping step 1 and 2 as we already have the partner server challenge + + baseAuthenticationServer := header.Get("Location") + baseAuthenticationServerURL, err := url.Parse(baseAuthenticationServer) + if err != nil { + return err + } + + // Skipping step 3 and 4 as we already know that we need and have the user's credentials + // Step 5 (Sign-in request) + authenticationServerUrl := url.URL{ + Scheme: baseAuthenticationServerURL.Scheme, + Host: baseAuthenticationServerURL.Host, + Path: "/login2.srf", + } + + partnerServerChallenge := strings.Split(header.Get("Www-Authenticate"), " ")[1] + + req := http.Request{ + Method: "GET", + URL: &authenticationServerUrl, + Header: http.Header{ + "Authorization": []string{"Passport1.4 sign-in=" + url.QueryEscape(p.user) + ",pwd=" + url.QueryEscape(p.pw) + ",OrgVerb=GET,OrgUrl=" + partnerUrl + "," + partnerServerChallenge}, + }, + } + + rs, err := c.Do(&req) + if err != nil { + return err + } + io.Copy(io.Discard, rs.Body) + rs.Body.Close() + if rs.StatusCode != 200 { + return NewPathError("Authorize", "/", rs.StatusCode) + } + + // Step 6 (Token Response from Authentication Server) + tokenResponseHeader := rs.Header.Get("Authentication-Info") + if tokenResponseHeader == "" { + return NewPathError("Authorize", "/", 401) + } + tokenResponseHeaderList := strings.Split(tokenResponseHeader, ",") + token := "" + for _, tokenResponseHeader := range tokenResponseHeaderList { + if strings.HasPrefix(tokenResponseHeader, "from-PP='") { + token = tokenResponseHeader + break + } + } + if token == "" { + return NewPathError("Authorize", "/", 401) + } + + // Step 7 (First Authentication Request to Partner Server) + origUrl, err := url.Parse(partnerUrl) + if err != nil { + return err + } + req = http.Request{ + Method: "GET", + URL: origUrl, + Header: http.Header{ + "Authorization": []string{"Passport1.4 " + token}, + }, + } + + rs, err = c.Do(&req) + if err != nil { + return err + } + io.Copy(io.Discard, rs.Body) + rs.Body.Close() + if rs.StatusCode != 200 && rs.StatusCode != 302 { + return NewPathError("Authorize", "/", rs.StatusCode) + } + + // Step 8 (Set Token Message from Partner Server) + cookies := rs.Header.Values("Set-Cookie") + p.cookies = make([]http.Cookie, len(cookies)) + for i, cookie := range cookies { + cookieParts := strings.Split(cookie, ";") + cookieName := strings.Split(cookieParts[0], "=")[0] + cookieValue := strings.Split(cookieParts[0], "=")[1] + + p.cookies[i] = http.Cookie{ + Name: cookieName, + Value: cookieValue, + } + } + + return nil +} diff --git a/godo/webdav/requests.go b/godo/webdav/requests.go new file mode 100644 index 0000000..1c59369 --- /dev/null +++ b/godo/webdav/requests.go @@ -0,0 +1,181 @@ +package webdav + +import ( + "io" + "log" + "net/http" + "path" + "strings" +) + +func (c *Client) req(method, path string, body io.Reader, intercept func(*http.Request)) (rs *http.Response, err error) { + var redo bool + var r *http.Request + var uri = PathEscape(Join(c.root, path)) + auth, body := c.auth.NewAuthenticator(body) + defer auth.Close() + + for { // TODO auth.continue() strategy(true|n times|until)? + if r, err = http.NewRequest(method, uri, body); err != nil { + return + } + + for k, vals := range c.headers { + for _, v := range vals { + r.Header.Add(k, v) + } + } + + if err = auth.Authorize(c.c, r, path); err != nil { + return + } + + if intercept != nil { + intercept(r) + } + + if c.interceptor != nil { + c.interceptor(method, r) + } + + if rs, err = c.c.Do(r); err != nil { + return + } + + if redo, err = auth.Verify(c.c, rs, path); err != nil { + rs.Body.Close() + return nil, err + } + if redo { + rs.Body.Close() + if body, err = r.GetBody(); err != nil { + return nil, err + } + continue + } + break + } + + return rs, err +} + +func (c *Client) mkcol(path string) (status int, err error) { + rs, err := c.req("MKCOL", path, nil, nil) + if err != nil { + return + } + defer rs.Body.Close() + + status = rs.StatusCode + if status == 405 { + status = 201 + } + + return +} + +func (c *Client) options(path string) (*http.Response, error) { + return c.req("OPTIONS", path, nil, func(rq *http.Request) { + rq.Header.Add("Depth", "0") + }) +} + +func (c *Client) propfind(path string, self bool, body string, resp interface{}, parse func(resp interface{}) error) error { + rs, err := c.req("PROPFIND", path, strings.NewReader(body), func(rq *http.Request) { + if self { + rq.Header.Add("Depth", "0") + } else { + rq.Header.Add("Depth", "1") + } + rq.Header.Add("Content-Type", "application/xml;charset=UTF-8") + rq.Header.Add("Accept", "application/xml,text/xml") + rq.Header.Add("Accept-Charset", "utf-8") + // TODO add support for 'gzip,deflate;q=0.8,q=0.7' + rq.Header.Add("Accept-Encoding", "") + }) + if err != nil { + return err + } + defer rs.Body.Close() + + if rs.StatusCode != 207 { + return NewPathError("PROPFIND", path, rs.StatusCode) + } + + return parseXML(rs.Body, resp, parse) +} + +func (c *Client) doCopyMove( + method string, + oldpath string, + newpath string, + overwrite bool, +) ( + status int, + r io.ReadCloser, + err error, +) { + rs, err := c.req(method, oldpath, nil, func(rq *http.Request) { + rq.Header.Add("Destination", PathEscape(Join(c.root, newpath))) + if overwrite { + rq.Header.Add("Overwrite", "T") + } else { + rq.Header.Add("Overwrite", "F") + } + }) + if err != nil { + return + } + status = rs.StatusCode + r = rs.Body + return +} + +func (c *Client) copymove(method string, oldpath string, newpath string, overwrite bool) (err error) { + s, data, err := c.doCopyMove(method, oldpath, newpath, overwrite) + if err != nil { + return + } + if data != nil { + defer data.Close() + } + + switch s { + case 201, 204: + return nil + + case 207: + // TODO handle multistat errors, worst case ... + log.Printf("TODO handle %s - %s multistatus result %s\n", method, oldpath, String(data)) + + case 409: + err := c.createParentCollection(newpath) + if err != nil { + return err + } + + return c.copymove(method, oldpath, newpath, overwrite) + } + + return NewPathError(method, oldpath, s) +} + +func (c *Client) put(path string, stream io.Reader) (status int, err error) { + rs, err := c.req("PUT", path, stream, nil) + if err != nil { + return + } + defer rs.Body.Close() + + status = rs.StatusCode + return +} + +func (c *Client) createParentCollection(itemPath string) (err error) { + parentPath := path.Dir(itemPath) + if parentPath == "." || parentPath == "/" { + return nil + } + + return c.MkdirAll(parentPath, 0755) +} diff --git a/godo/webdav/utils.go b/godo/webdav/utils.go new file mode 100644 index 0000000..9f97aa6 --- /dev/null +++ b/godo/webdav/utils.go @@ -0,0 +1,113 @@ +package webdav + +import ( + "bytes" + "encoding/xml" + "io" + "net/url" + "strconv" + "strings" + "time" +) + +// PathEscape escapes all segments of a given path +func PathEscape(path string) string { + s := strings.Split(path, "/") + for i, e := range s { + s[i] = url.PathEscape(e) + } + return strings.Join(s, "/") +} + +// FixSlash appends a trailing / to our string +func FixSlash(s string) string { + if !strings.HasSuffix(s, "/") { + s += "/" + } + return s +} + +// FixSlashes appends and prepends a / if they are missing +func FixSlashes(s string) string { + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + + return FixSlash(s) +} + +// Join joins two paths +func Join(path0 string, path1 string) string { + return strings.TrimSuffix(path0, "/") + "/" + strings.TrimPrefix(path1, "/") +} + +// String pulls a string out of our io.Reader +func String(r io.Reader) string { + buf := new(bytes.Buffer) + // TODO - make String return an error as well + _, _ = buf.ReadFrom(r) + return buf.String() +} + +func parseUint(s *string) uint { + if n, e := strconv.ParseUint(*s, 10, 32); e == nil { + return uint(n) + } + return 0 +} + +func parseInt64(s *string) int64 { + if n, e := strconv.ParseInt(*s, 10, 64); e == nil { + return n + } + return 0 +} + +func parseModified(s *string) time.Time { + if t, e := time.Parse(time.RFC1123, *s); e == nil { + return t + } + return time.Unix(0, 0) +} + +func parseXML(data io.Reader, resp interface{}, parse func(resp interface{}) error) error { + decoder := xml.NewDecoder(data) + for t, _ := decoder.Token(); t != nil; t, _ = decoder.Token() { + switch se := t.(type) { + case xml.StartElement: + if se.Name.Local == "response" { + if e := decoder.DecodeElement(resp, &se); e == nil { + if err := parse(resp); err != nil { + return err + } + } + } + } + } + return nil +} + +// limitedReadCloser wraps a io.ReadCloser and limits the number of bytes that can be read from it. +type limitedReadCloser struct { + rc io.ReadCloser + remaining int +} + +func (l *limitedReadCloser) Read(buf []byte) (int, error) { + if l.remaining <= 0 { + return 0, io.EOF + } + + if len(buf) > l.remaining { + buf = buf[0:l.remaining] + } + + n, err := l.rc.Read(buf) + l.remaining -= n + + return n, err +} + +func (l *limitedReadCloser) Close() error { + return l.rc.Close() +}