From f88ef1394ec924c5c13f80b846f94596411fde4a Mon Sep 17 00:00:00 2001 From: veypi Date: Sun, 29 May 2022 16:23:17 +0800 Subject: [PATCH] update v1 --- file/main.go | 2 +- main.go | 1 + oaf/src/main.ts | 3 +- oaf/src/oaer/src/api/setting.ts | 5 +- oaf/src/oaer/src/components/file.vue | 23 +- oaf/src/oaer/src/frame.vue | 2 +- oaf/src/oaer/src/libs/webdav/auth/basic.ts | 7 + oaf/src/oaer/src/libs/webdav/auth/digest.ts | 82 +++++ oaf/src/oaer/src/libs/webdav/auth/index.ts | 36 +++ oaf/src/oaer/src/libs/webdav/auth/oauth.ts | 5 + .../src/libs/webdav/compat/arrayBuffer.ts | 10 + oaf/src/oaer/src/libs/webdav/compat/buffer.ts | 8 + .../oaer/src/libs/webdav/compat/patcher.ts | 10 + oaf/src/oaer/src/libs/webdav/factory.ts | 114 +++++++ oaf/src/oaer/src/libs/webdav/index.ts | 5 + .../src/libs/webdav/operations/copyFile.ts | 26 ++ .../libs/webdav/operations/createDirectory.ts | 81 +++++ .../libs/webdav/operations/createStream.ts | 109 +++++++ .../libs/webdav/operations/customRequest.ts | 19 ++ .../src/libs/webdav/operations/deleteFile.ts | 22 ++ .../webdav/operations/directoryContents.ts | 78 +++++ .../oaer/src/libs/webdav/operations/exists.ts | 18 ++ .../libs/webdav/operations/getFileContents.ts | 102 +++++++ .../src/libs/webdav/operations/getQuota.ts | 30 ++ .../oaer/src/libs/webdav/operations/lock.ts | 79 +++++ .../src/libs/webdav/operations/moveFile.ts | 26 ++ .../libs/webdav/operations/putFileContents.ts | 94 ++++++ .../oaer/src/libs/webdav/operations/stat.ts | 32 ++ oaf/src/oaer/src/libs/webdav/request.ts | 108 +++++++ oaf/src/oaer/src/libs/webdav/response.ts | 46 +++ oaf/src/oaer/src/libs/webdav/tools/crypto.ts | 16 + oaf/src/oaer/src/libs/webdav/tools/dav.ts | 171 +++++++++++ oaf/src/oaer/src/libs/webdav/tools/encode.ts | 24 ++ oaf/src/oaer/src/libs/webdav/tools/headers.ts | 18 ++ oaf/src/oaer/src/libs/webdav/tools/merge.ts | 62 ++++ oaf/src/oaer/src/libs/webdav/tools/path.ts | 36 +++ oaf/src/oaer/src/libs/webdav/tools/quota.ts | 22 ++ oaf/src/oaer/src/libs/webdav/tools/size.ts | 22 ++ oaf/src/oaer/src/libs/webdav/tools/url.ts | 32 ++ oaf/src/oaer/src/libs/webdav/tools/xml.ts | 55 ++++ oaf/src/oaer/src/libs/webdav/types.ts | 288 ++++++++++++++++++ oaf/src/oaer/src/main.vue | 4 - oaf/tsconfig.json | 3 +- 43 files changed, 1922 insertions(+), 14 deletions(-) create mode 100644 oaf/src/oaer/src/libs/webdav/auth/basic.ts create mode 100644 oaf/src/oaer/src/libs/webdav/auth/digest.ts create mode 100644 oaf/src/oaer/src/libs/webdav/auth/index.ts create mode 100644 oaf/src/oaer/src/libs/webdav/auth/oauth.ts create mode 100644 oaf/src/oaer/src/libs/webdav/compat/arrayBuffer.ts create mode 100644 oaf/src/oaer/src/libs/webdav/compat/buffer.ts create mode 100644 oaf/src/oaer/src/libs/webdav/compat/patcher.ts create mode 100644 oaf/src/oaer/src/libs/webdav/factory.ts create mode 100644 oaf/src/oaer/src/libs/webdav/index.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/copyFile.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/createDirectory.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/createStream.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/customRequest.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/deleteFile.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/directoryContents.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/exists.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/getFileContents.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/getQuota.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/lock.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/moveFile.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/putFileContents.ts create mode 100644 oaf/src/oaer/src/libs/webdav/operations/stat.ts create mode 100644 oaf/src/oaer/src/libs/webdav/request.ts create mode 100644 oaf/src/oaer/src/libs/webdav/response.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/crypto.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/dav.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/encode.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/headers.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/merge.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/path.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/quota.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/size.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/url.ts create mode 100644 oaf/src/oaer/src/libs/webdav/tools/xml.ts create mode 100644 oaf/src/oaer/src/libs/webdav/types.ts diff --git a/file/main.go b/file/main.go index 4316299..024bce8 100644 --- a/file/main.go +++ b/file/main.go @@ -24,7 +24,7 @@ func Router(r OneBD.Router) { MountFunc: userFileChecker, } log.Info().Msgf("start file server on %s", cfg.CFG.Host) - r.Set("/usr/", usrF.ServeHTTP, rfc.MethodAll) + r.Set("/usr", usrF.ServeHTTP, rfc.MethodAll) r.Set("/usr/*", usrF.ServeHTTP, rfc.MethodAll) // 应用存储文件 appF := fs.FS{ diff --git a/main.go b/main.go index 0197ef5..5a26f79 100644 --- a/main.go +++ b/main.go @@ -63,5 +63,6 @@ func main() { return nil } _ = app.Run(os.Args) + srv.Run() } diff --git a/oaf/src/main.ts b/oaf/src/main.ts index 641b63e..dfd70c9 100644 --- a/oaf/src/main.ts +++ b/oaf/src/main.ts @@ -4,8 +4,9 @@ import router from './router' import {store, key} from './store' import OneIcon from '@veypi/one-icon' import naive from 'naive-ui' -import './index.css' import {Api} from './api' + +import './index.css' import './assets/icon.js' import 'animate.css' diff --git a/oaf/src/oaer/src/api/setting.ts b/oaf/src/oaer/src/api/setting.ts index a523272..9285248 100644 --- a/oaf/src/oaer/src/api/setting.ts +++ b/oaf/src/oaer/src/api/setting.ts @@ -8,7 +8,7 @@ import {ref} from 'vue' -export let Cfg= { +export let Cfg = { token: ref(''), uuid: ref(''), host: ref(''), @@ -16,4 +16,7 @@ export let Cfg= { BaseUrl() { return this.host.value + this.prefix }, + userFileUrl() { + return (this.host.value || window.location.href) + '/file/usr/' + }, } diff --git a/oaf/src/oaer/src/components/file.vue b/oaf/src/oaer/src/components/file.vue index 335d58a..17a0ed0 100644 --- a/oaf/src/oaer/src/components/file.vue +++ b/oaf/src/oaer/src/components/file.vue @@ -21,22 +21,35 @@ 获取挂载链接 - - - 内容 - + + + {{ Cfg.userFileUrl() }} +
diff --git a/oaf/src/oaer/src/frame.vue b/oaf/src/oaer/src/frame.vue index 1d33dec..813f075 100644 --- a/oaf/src/oaer/src/frame.vue +++ b/oaf/src/oaer/src/frame.vue @@ -8,7 +8,7 @@
-
+
diff --git a/oaf/src/oaer/src/libs/webdav/auth/basic.ts b/oaf/src/oaer/src/libs/webdav/auth/basic.ts new file mode 100644 index 0000000..98ea430 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/auth/basic.ts @@ -0,0 +1,7 @@ +import { toBase64 } from "../tools/encode"; +import { AuthHeader } from "../types"; + +export function generateBasicAuthHeader(username: string, password: string): AuthHeader { + const encoded = toBase64(`${username}:${password}`); + return `Basic ${encoded}`; +} diff --git a/oaf/src/oaer/src/libs/webdav/auth/digest.ts b/oaf/src/oaer/src/libs/webdav/auth/digest.ts new file mode 100644 index 0000000..63abdaa --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/auth/digest.ts @@ -0,0 +1,82 @@ +import md5 from "md5"; +import { ha1Compute } from "../tools/crypto"; +import { DigestContext, Response } from "../types"; + +const NONCE_CHARS = "abcdef0123456789"; +const NONCE_SIZE = 32; + +export function createDigestContext(username: string, password: string): DigestContext { + return { username, password, nc: 0, algorithm: "md5", hasDigestAuth: false }; +} + +export function generateDigestAuthHeader(options, digest: DigestContext): string { + const url = options.url.replace("//", ""); + const uri = url.indexOf("/") == -1 ? "/" : url.slice(url.indexOf("/")); + const method = options.method ? options.method.toUpperCase() : "GET"; + const qop = /(^|,)\s*auth\s*($|,)/.test(digest.qop) ? "auth" : false; + const ncString = `00000000${digest.nc}`.slice(-8); + const ha1 = ha1Compute( + digest.algorithm, + digest.username, + digest.realm, + digest.password, + digest.nonce, + digest.cnonce + ); + const ha2 = md5(`${method}:${uri}`); + const digestResponse = qop + ? md5(`${ha1}:${digest.nonce}:${ncString}:${digest.cnonce}:${qop}:${ha2}`) + : md5(`${ha1}:${digest.nonce}:${ha2}`); + + const authValues = { + username: digest.username, + realm: digest.realm, + nonce: digest.nonce, + uri, + qop, + response: digestResponse, + nc: ncString, + cnonce: digest.cnonce, + algorithm: digest.algorithm, + opaque: digest.opaque + }; + + const authHeader = []; + for (const k in authValues) { + if (authValues[k]) { + if (k === "qop" || k === "nc" || k === "algorithm") { + authHeader.push(`${k}=${authValues[k]}`); + } else { + authHeader.push(`${k}="${authValues[k]}"`); + } + } + } + + return `Digest ${authHeader.join(", ")}`; +} + +function makeNonce(): string { + let uid = ""; + for (let i = 0; i < NONCE_SIZE; ++i) { + uid = `${uid}${NONCE_CHARS[Math.floor(Math.random() * NONCE_CHARS.length)]}`; + } + return uid; +} + +export function parseDigestAuth(response: Response, _digest: DigestContext): boolean { + const authHeader = response.headers["www-authenticate"] || ""; + if (authHeader.split(/\s/)[0].toLowerCase() !== "digest") { + return false; + } + const re = /([a-z0-9_-]+)=(?:"([^"]+)"|([a-z0-9_-]+))/gi; + for (;;) { + const match = re.exec(authHeader); + if (!match) { + break; + } + _digest[match[1]] = match[2] || match[3]; + } + _digest.nc += 1; + _digest.cnonce = makeNonce(); + return true; +} diff --git a/oaf/src/oaer/src/libs/webdav/auth/index.ts b/oaf/src/oaer/src/libs/webdav/auth/index.ts new file mode 100644 index 0000000..3758b29 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/auth/index.ts @@ -0,0 +1,36 @@ +import { Layerr } from "layerr"; +import { createDigestContext } from "./digest"; +import { generateBasicAuthHeader } from "./basic"; +import { generateTokenAuthHeader } from "./oauth"; +import { AuthType, ErrorCode, OAuthToken, WebDAVClientContext } from "../types"; + +export function setupAuth( + context: WebDAVClientContext, + username: string, + password: string, + oauthToken: OAuthToken +): void { + switch (context.authType) { + case AuthType.Digest: + context.digest = createDigestContext(username, password); + break; + case AuthType.None: + // Do nothing + break; + case AuthType.Password: + context.headers.Authorization = generateBasicAuthHeader(username, password); + break; + case AuthType.Token: + context.headers.Authorization = generateTokenAuthHeader(oauthToken); + break; + default: + throw new Layerr( + { + info: { + code: ErrorCode.InvalidAuthType + } + }, + `Invalid auth type: ${context.authType}` + ); + } +} diff --git a/oaf/src/oaer/src/libs/webdav/auth/oauth.ts b/oaf/src/oaer/src/libs/webdav/auth/oauth.ts new file mode 100644 index 0000000..7261c91 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/auth/oauth.ts @@ -0,0 +1,5 @@ +import { AuthHeader, OAuthToken } from "../types"; + +export function generateTokenAuthHeader(token: OAuthToken): AuthHeader { + return `${token.token_type} ${token.access_token}`; +} diff --git a/oaf/src/oaer/src/libs/webdav/compat/arrayBuffer.ts b/oaf/src/oaer/src/libs/webdav/compat/arrayBuffer.ts new file mode 100644 index 0000000..750dcfc --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/compat/arrayBuffer.ts @@ -0,0 +1,10 @@ +const hasArrayBuffer = typeof ArrayBuffer === "function"; +const { toString: objToString } = Object.prototype; + +// Taken from: https://github.com/fengyuanchen/is-array-buffer/blob/master/src/index.js +export function isArrayBuffer(value: any): boolean { + return ( + hasArrayBuffer && + (value instanceof ArrayBuffer || objToString.call(value) === "[object ArrayBuffer]") + ); +} diff --git a/oaf/src/oaer/src/libs/webdav/compat/buffer.ts b/oaf/src/oaer/src/libs/webdav/compat/buffer.ts new file mode 100644 index 0000000..5133dd9 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/compat/buffer.ts @@ -0,0 +1,8 @@ +export function isBuffer(value: any): boolean { + return ( + value != null && + value.constructor != null && + typeof value.constructor.isBuffer === "function" && + value.constructor.isBuffer(value) + ); +} diff --git a/oaf/src/oaer/src/libs/webdav/compat/patcher.ts b/oaf/src/oaer/src/libs/webdav/compat/patcher.ts new file mode 100644 index 0000000..adfb722 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/compat/patcher.ts @@ -0,0 +1,10 @@ +import HotPatcher from "hot-patcher"; + +let __patcher: HotPatcher = null; + +export function getPatcher(): HotPatcher { + if (!__patcher) { + __patcher = new HotPatcher(); + } + return __patcher; +} diff --git a/oaf/src/oaer/src/libs/webdav/factory.ts b/oaf/src/oaer/src/libs/webdav/factory.ts new file mode 100644 index 0000000..6a4ee6e --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/factory.ts @@ -0,0 +1,114 @@ +import Stream from "stream"; +import { extractURLPath } from "./tools/url"; +import { setupAuth } from "./auth/index"; +import { copyFile } from "./operations/copyFile"; +import { createDirectory } from "./operations/createDirectory"; +import { createReadStream, createWriteStream } from "./operations/createStream"; +import { customRequest } from "./operations/customRequest"; +import { deleteFile } from "./operations/deleteFile"; +import { exists } from "./operations/exists"; +import { getDirectoryContents } from "./operations/directoryContents"; +import { getFileContents, getFileDownloadLink } from "./operations/getFileContents"; +import { lock, unlock } from "./operations/lock"; +import { getQuota } from "./operations/getQuota"; +import { getStat } from "./operations/stat"; +import { moveFile } from "./operations/moveFile"; +import { getFileUploadLink, putFileContents } from "./operations/putFileContents"; +import { + AuthType, + BufferLike, + CreateReadStreamOptions, + CreateWriteStreamCallback, + CreateWriteStreamOptions, + GetDirectoryContentsOptions, + GetFileContentsOptions, + GetQuotaOptions, + Headers, + LockOptions, + PutFileContentsOptions, + RequestOptionsCustom, + StatOptions, + WebDAVClient, + WebDAVClientContext, + WebDAVClientOptions, + WebDAVMethodOptions +} from "./types"; + +const DEFAULT_CONTACT_HREF = + "https://github.com/perry-mitchell/webdav-client/blob/master/LOCK_CONTACT.md"; + +export function createClient(remoteURL: string, options: WebDAVClientOptions = {}): WebDAVClient { + const { + authType: authTypeRaw = null, + contactHref = DEFAULT_CONTACT_HREF, + headers = {}, + httpAgent, + httpsAgent, + maxBodyLength, + maxContentLength, + password, + token, + username, + withCredentials + } = options; + let authType = authTypeRaw; + if (!authType) { + authType = username || password ? AuthType.Password : AuthType.None; + } + const context: WebDAVClientContext = { + authType, + contactHref, + headers: Object.assign({}, headers), + httpAgent, + httpsAgent, + maxBodyLength, + maxContentLength, + remotePath: extractURLPath(remoteURL), + remoteURL, + password, + token, + username, + withCredentials + }; + setupAuth(context, username, password, token); + return { + copyFile: (filename: string, destination: string, options?: WebDAVMethodOptions) => + copyFile(context, filename, destination, options), + createDirectory: (path: string, options?: WebDAVMethodOptions) => + createDirectory(context, path, options), + createReadStream: (filename: string, options?: CreateReadStreamOptions) => + createReadStream(context, filename, options), + createWriteStream: ( + filename: string, + options?: CreateWriteStreamOptions, + callback?: CreateWriteStreamCallback + ) => createWriteStream(context, filename, options, callback), + customRequest: (path: string, requestOptions: RequestOptionsCustom) => + customRequest(context, path, requestOptions), + deleteFile: (filename: string, options?: WebDAVMethodOptions) => + deleteFile(context, filename, options), + exists: (path: string, options?: WebDAVMethodOptions) => exists(context, path, options), + getDirectoryContents: (path: string, options?: GetDirectoryContentsOptions) => + getDirectoryContents(context, path, options), + getFileContents: (filename: string, options?: GetFileContentsOptions) => + getFileContents(context, filename, options), + getFileDownloadLink: (filename: string) => getFileDownloadLink(context, filename), + getFileUploadLink: (filename: string) => getFileUploadLink(context, filename), + getHeaders: () => Object.assign({}, context.headers), + getQuota: (options?: GetQuotaOptions) => getQuota(context, options), + lock: (path: string, options?: LockOptions) => lock(context, path, options), + moveFile: (filename: string, destinationFilename: string, options?: WebDAVMethodOptions) => + moveFile(context, filename, destinationFilename, options), + putFileContents: ( + filename: string, + data: string | BufferLike | Stream.Readable, + options?: PutFileContentsOptions + ) => putFileContents(context, filename, data, options), + setHeaders: (headers: Headers) => { + context.headers = Object.assign({}, headers); + }, + stat: (path: string, options?: StatOptions) => getStat(context, path, options), + unlock: (path: string, token: string, options?: WebDAVMethodOptions) => + unlock(context, path, token, options) + }; +} diff --git a/oaf/src/oaer/src/libs/webdav/index.ts b/oaf/src/oaer/src/libs/webdav/index.ts new file mode 100644 index 0000000..defd0f6 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/index.ts @@ -0,0 +1,5 @@ +export { createClient } from "./factory"; +export { getPatcher } from "./compat/patcher"; +export * from "./types"; + +export { parseStat, parseXML } from "./tools/dav"; diff --git a/oaf/src/oaer/src/libs/webdav/operations/copyFile.ts b/oaf/src/oaer/src/libs/webdav/operations/copyFile.ts new file mode 100644 index 0000000..cd12627 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/copyFile.ts @@ -0,0 +1,26 @@ +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { WebDAVClientContext, WebDAVMethodOptions } from "../types"; + +export async function copyFile( + context: WebDAVClientContext, + filename: string, + destination: string, + options: WebDAVMethodOptions = {} +): Promise { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filename)), + method: "COPY", + headers: { + Destination: joinURL(context.remoteURL, encodePath(destination)) + } + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/createDirectory.ts b/oaf/src/oaer/src/libs/webdav/operations/createDirectory.ts new file mode 100644 index 0000000..f75b06b --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/createDirectory.ts @@ -0,0 +1,81 @@ +import { joinURL } from "../tools/url"; +import { encodePath, getAllDirectories, normalisePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { getStat } from "./stat"; +import { CreateDirectoryOptions, FileStat, WebDAVClientContext, WebDAVClientError } from "../types"; + +export async function createDirectory( + context: WebDAVClientContext, + dirPath: string, + options: CreateDirectoryOptions = {} +): Promise { + if (options.recursive === true) return createDirectoryRecursively(context, dirPath, options); + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, ensureCollectionPath(encodePath(dirPath))), + method: "MKCOL" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); +} + +/** + * Ensure the path is a proper "collection" path by ensuring it has a trailing "/". + * The proper format of collection according to the specification does contain the trailing slash. + * http://www.webdav.org/specs/rfc4918.html#rfc.section.5.2 + * @param path Path of the collection + * @return string Path of the collection with appended trailing "/" in case the `path` does not have it. + */ +function ensureCollectionPath(path: string): string { + if (!path.endsWith("/")) { + return path + "/"; + } + return path; +} + +async function createDirectoryRecursively( + context: WebDAVClientContext, + dirPath: string, + options: CreateDirectoryOptions = {} +): Promise { + const paths = getAllDirectories(normalisePath(dirPath)); + paths.sort((a, b) => { + if (a.length > b.length) { + return 1; + } else if (b.length > a.length) { + return -1; + } + return 0; + }); + let creating: boolean = false; + for (const testPath of paths) { + if (creating) { + await createDirectory(context, testPath, { + ...options, + recursive: false + }); + continue; + } + try { + const testStat = (await getStat(context, testPath)) as FileStat; + if (testStat.type !== "directory") { + throw new Error(`Path includes a file: ${dirPath}`); + } + } catch (err) { + const error = err as WebDAVClientError; + if (error.status === 404) { + creating = true; + await createDirectory(context, testPath, { + ...options, + recursive: false + }); + } else { + throw err; + } + } + } +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/createStream.ts b/oaf/src/oaer/src/libs/webdav/operations/createStream.ts new file mode 100644 index 0000000..eea29a8 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/createStream.ts @@ -0,0 +1,109 @@ +import Stream from "stream"; +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { + CreateReadStreamOptions, + CreateWriteStreamCallback, + CreateWriteStreamOptions, + Headers, + WebDAVClientContext, + WebDAVClientError +} from "../types"; + +const NOOP = () => {}; + +export function createReadStream( + context: WebDAVClientContext, + filePath: string, + options: CreateReadStreamOptions = {} +): Stream.Readable { + const PassThroughStream = Stream.PassThrough; + const outStream = new PassThroughStream(); + getFileStream(context, filePath, options) + .then(stream => { + stream.pipe(outStream); + }) + .catch(err => { + outStream.emit("error", err); + }); + return outStream; +} + +export function createWriteStream( + context: WebDAVClientContext, + filePath: string, + options: CreateWriteStreamOptions = {}, + callback: CreateWriteStreamCallback = NOOP +): Stream.Writable { + const PassThroughStream = Stream.PassThrough; + const writeStream = new PassThroughStream(); + const headers = {}; + if (options.overwrite === false) { + headers["If-None-Match"] = "*"; + } + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "PUT", + headers, + data: writeStream, + maxRedirects: 0 + }, + context, + options + ); + request(requestOptions) + .then(response => handleResponseCode(context, response)) + .then(response => { + // Fire callback asynchronously to avoid errors + setTimeout(() => { + callback(response); + }, 0); + }) + .catch(err => { + writeStream.emit("error", err); + }); + return writeStream; +} + +async function getFileStream( + context: WebDAVClientContext, + filePath: string, + options: CreateReadStreamOptions = {} +): Promise { + const headers: Headers = {}; + if (typeof options.range === "object" && typeof options.range.start === "number") { + let rangeHeader = `bytes=${options.range.start}-`; + if (typeof options.range.end === "number") { + rangeHeader = `${rangeHeader}${options.range.end}`; + } + headers.Range = rangeHeader; + } + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "GET", + headers, + responseType: "stream" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + if (headers.Range && response.status !== 206) { + const responseError: WebDAVClientError = new Error( + `Invalid response code for partial request: ${response.status}` + ); + responseError.status = response.status; + throw responseError; + } + if (options.callback) { + setTimeout(() => { + options.callback(response); + }, 0); + } + return response.data as Stream.Readable; +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/customRequest.ts b/oaf/src/oaer/src/libs/webdav/operations/customRequest.ts new file mode 100644 index 0000000..cc31dba --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/customRequest.ts @@ -0,0 +1,19 @@ +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { RequestOptionsCustom, Response, WebDAVClientContext } from "../types"; + +export async function customRequest( + context: WebDAVClientContext, + remotePath: string, + requestOptions: RequestOptionsCustom +): Promise { + if (!requestOptions.url) { + requestOptions.url = joinURL(context.remoteURL, encodePath(remotePath)); + } + const finalOptions = prepareRequestOptions(requestOptions, context, {}); + const response = await request(finalOptions); + handleResponseCode(context, response); + return response; +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/deleteFile.ts b/oaf/src/oaer/src/libs/webdav/operations/deleteFile.ts new file mode 100644 index 0000000..fd3f509 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/deleteFile.ts @@ -0,0 +1,22 @@ +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { WebDAVClientContext, WebDAVMethodOptions } from "../types"; + +export async function deleteFile( + context: WebDAVClientContext, + filename: string, + options: WebDAVMethodOptions = {} +): Promise { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filename)), + method: "DELETE" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/directoryContents.ts b/oaf/src/oaer/src/libs/webdav/operations/directoryContents.ts new file mode 100644 index 0000000..8988e16 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/directoryContents.ts @@ -0,0 +1,78 @@ +import pathPosix from "path-posix"; +import { joinURL, normaliseHREF } from "../tools/url"; +import { encodePath, normalisePath } from "../tools/path"; +import { parseXML, prepareFileFromProps } from "../tools/dav"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode, processGlobFilter, processResponsePayload } from "../response"; +import { + DAVResult, + FileStat, + GetDirectoryContentsOptions, + ResponseDataDetailed, + WebDAVClientContext +} from "../types"; + +export async function getDirectoryContents( + context: WebDAVClientContext, + remotePath: string, + options: GetDirectoryContentsOptions = {} +): Promise | ResponseDataDetailed>> { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(remotePath), "/"), + method: "PROPFIND", + headers: { + Accept: "text/plain", + Depth: options.deep ? "infinity" : "1" + }, + responseType: "text" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + const davResp = await parseXML(response.data as string); + let files = getDirectoryFiles(davResp, context.remotePath, remotePath, options.details); + if (options.glob) { + files = processGlobFilter(files, options.glob); + } + return processResponsePayload(response, files, options.details); +} + +function getDirectoryFiles( + result: DAVResult, + serverBasePath: string, + requestPath: string, + isDetailed: boolean = false +): Array { + const serverBase = pathPosix.join(serverBasePath, "/"); + // Extract the response items (directory contents) + const { + multistatus: { response: responseItems } + } = result; + return ( + responseItems + // Map all items to a consistent output structure (results) + .map(item => { + // HREF is the file path (in full) + const href = normaliseHREF(item.href); + // Each item should contain a stat object + const { + propstat: { prop: props } + } = item; + // Process the true full filename (minus the base server path) + const filename = + serverBase === "/" + ? decodeURIComponent(normalisePath(href)) + : decodeURIComponent(normalisePath(pathPosix.relative(serverBase, href))); + return prepareFileFromProps(props, filename, isDetailed); + }) + // Filter out the item pointing to the current directory (not needed) + .filter( + item => + item.basename && + (item.type === "file" || item.filename !== requestPath.replace(/\/$/, "")) + ) + ); +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/exists.ts b/oaf/src/oaer/src/libs/webdav/operations/exists.ts new file mode 100644 index 0000000..6e79b45 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/exists.ts @@ -0,0 +1,18 @@ +import { getStat } from "./stat"; +import { WebDAVClientContext, WebDAVMethodOptions } from "../types"; + +export async function exists( + context: WebDAVClientContext, + remotePath: string, + options: WebDAVMethodOptions = {} +): Promise { + try { + await getStat(context, remotePath, options); + return true; + } catch (err) { + if (err.status === 404) { + return false; + } + throw err; + } +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/getFileContents.ts b/oaf/src/oaer/src/libs/webdav/operations/getFileContents.ts new file mode 100644 index 0000000..4373f78 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/getFileContents.ts @@ -0,0 +1,102 @@ +import { Layerr } from "layerr"; +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { fromBase64 } from "../tools/encode"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode, processResponsePayload } from "../response"; +import { + AuthType, + BufferLike, + ErrorCode, + GetFileContentsOptions, + ResponseDataDetailed, + WebDAVClientContext +} from "../types"; + +const TRANSFORM_RETAIN_FORMAT = (v: any) => v; + +export async function getFileContents( + context: WebDAVClientContext, + filePath: string, + options: GetFileContentsOptions = {} +): Promise> { + const { format = "binary" } = options; + if (format !== "binary" && format !== "text") { + throw new Layerr( + { + info: { + code: ErrorCode.InvalidOutputFormat + } + }, + `Invalid output format: ${format}` + ); + } + return format === "text" + ? getFileContentsString(context, filePath, options) + : getFileContentsBuffer(context, filePath, options); +} + +async function getFileContentsBuffer( + context: WebDAVClientContext, + filePath: string, + options: GetFileContentsOptions = {} +): Promise> { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "GET", + responseType: "arraybuffer" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + return processResponsePayload(response, response.data as BufferLike, options.details); +} + +async function getFileContentsString( + context: WebDAVClientContext, + filePath: string, + options: GetFileContentsOptions = {} +): Promise> { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "GET", + responseType: "text", + transformResponse: [TRANSFORM_RETAIN_FORMAT] + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + return processResponsePayload(response, response.data as string, options.details); +} + +export function getFileDownloadLink(context: WebDAVClientContext, filePath: string): string { + let url = joinURL(context.remoteURL, encodePath(filePath)); + const protocol = /^https:/i.test(url) ? "https" : "http"; + switch (context.authType) { + case AuthType.None: + // Do nothing + break; + case AuthType.Password: { + const authPart = context.headers.Authorization.replace(/^Basic /i, "").trim(); + const authContents = fromBase64(authPart); + url = url.replace(/^https?:\/\//, `${protocol}://${authContents}@`); + break; + } + default: + throw new Layerr( + { + info: { + code: ErrorCode.LinkUnsupportedAuthType + } + }, + `Unsupported auth type for file link: ${context.authType}` + ); + } + return url; +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/getQuota.ts b/oaf/src/oaer/src/libs/webdav/operations/getQuota.ts new file mode 100644 index 0000000..70b8206 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/getQuota.ts @@ -0,0 +1,30 @@ +import { prepareRequestOptions, request } from "../request"; +import { handleResponseCode, processResponsePayload } from "../response"; +import { parseXML } from "../tools/dav"; +import { joinURL } from "../tools/url"; +import { parseQuota } from "../tools/quota"; +import { DiskQuota, GetQuotaOptions, ResponseDataDetailed, WebDAVClientContext } from "../types"; + +export async function getQuota( + context: WebDAVClientContext, + options: GetQuotaOptions = {} +): Promise> { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, "/"), + method: "PROPFIND", + headers: { + Accept: "text/plain", + Depth: "0" + }, + responseType: "text" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + const result = await parseXML(response.data as string); + const quota = parseQuota(result); + return processResponsePayload(response, quota, options.details); +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/lock.ts b/oaf/src/oaer/src/libs/webdav/operations/lock.ts new file mode 100644 index 0000000..316a357 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/lock.ts @@ -0,0 +1,79 @@ +import nestedProp from "nested-property"; +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { generateLockXML, parseGenericResponse } from "../tools/xml"; +import { request, prepareRequestOptions } from "../request"; +import { createErrorFromResponse, handleResponseCode } from "../response"; +import { + Headers, + LockOptions, + LockResponse, + WebDAVClientContext, + WebDAVMethodOptions +} from "../types"; + +const DEFAULT_TIMEOUT = "Infinite, Second-4100000000"; + +export async function lock( + context: WebDAVClientContext, + path: string, + options: LockOptions = {} +): Promise { + const { refreshToken, timeout = DEFAULT_TIMEOUT } = options; + const headers: Headers = { + Accept: "text/plain,application/xml", + Timeout: timeout + }; + if (refreshToken) { + headers.If = refreshToken; + } + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(path)), + method: "LOCK", + headers, + data: generateLockXML(context.contactHref), + responseType: "text" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + const lockPayload = parseGenericResponse(response.data as string); + const token = nestedProp.get(lockPayload, "prop.lockdiscovery.activelock.locktoken.href"); + const serverTimeout = nestedProp.get(lockPayload, "prop.lockdiscovery.activelock.timeout"); + if (!token) { + const err = createErrorFromResponse(response, "No lock token received: "); + throw err; + } + return { + token, + serverTimeout + }; +} + +export async function unlock( + context: WebDAVClientContext, + path: string, + token: string, + options: WebDAVMethodOptions = {} +): Promise { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(path)), + method: "UNLOCK", + headers: { + "Lock-Token": token + } + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + if (response.status !== 204 && response.status !== 200) { + const err = createErrorFromResponse(response); + throw err; + } +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/moveFile.ts b/oaf/src/oaer/src/libs/webdav/operations/moveFile.ts new file mode 100644 index 0000000..f0d67c5 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/moveFile.ts @@ -0,0 +1,26 @@ +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { WebDAVClientContext, WebDAVMethodOptions } from "../types"; + +export async function moveFile( + context: WebDAVClientContext, + filename: string, + destination: string, + options: WebDAVMethodOptions = {} +): Promise { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filename)), + method: "MOVE", + headers: { + Destination: joinURL(context.remoteURL, encodePath(destination)) + } + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/putFileContents.ts b/oaf/src/oaer/src/libs/webdav/operations/putFileContents.ts new file mode 100644 index 0000000..b4aa784 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/putFileContents.ts @@ -0,0 +1,94 @@ +import { Layerr } from "layerr"; +import Stream from "stream"; +import { fromBase64 } from "../tools/encode"; +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode } from "../response"; +import { calculateDataLength } from "../tools/size"; +import { + AuthType, + BufferLike, + ErrorCode, + Headers, + PutFileContentsOptions, + WebDAVClientContext, + WebDAVClientError +} from "../types"; + +declare var WEB: boolean; + +export async function putFileContents( + context: WebDAVClientContext, + filePath: string, + data: string | BufferLike | Stream.Readable, + options: PutFileContentsOptions = {} +): Promise { + const { contentLength = true, overwrite = true } = options; + const headers: Headers = { + "Content-Type": "application/octet-stream" + }; + if (typeof WEB === "undefined") { + // Skip, no content-length + } else if (contentLength === false) { + // Skip, disabled + } else if (typeof contentLength === "number") { + headers["Content-Length"] = `${contentLength}`; + } else { + headers["Content-Length"] = `${calculateDataLength(data as string | BufferLike)}`; + } + if (!overwrite) { + headers["If-None-Match"] = "*"; + } + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "PUT", + headers, + data + }, + context, + options + ); + const response = await request(requestOptions); + try { + handleResponseCode(context, response); + } catch (err) { + const error = err as WebDAVClientError; + if (error.status === 412 && !overwrite) { + return false; + } else { + throw error; + } + } + return true; +} + +export function getFileUploadLink(context: WebDAVClientContext, filePath: string): string { + let url: string = `${joinURL( + context.remoteURL, + encodePath(filePath) + )}?Content-Type=application/octet-stream`; + const protocol = /^https:/i.test(url) ? "https" : "http"; + switch (context.authType) { + case AuthType.None: + // Do nothing + break; + case AuthType.Password: { + const authPart = context.headers.Authorization.replace(/^Basic /i, "").trim(); + const authContents = fromBase64(authPart); + url = url.replace(/^https?:\/\//, `${protocol}://${authContents}@`); + break; + } + default: + throw new Layerr( + { + info: { + code: ErrorCode.LinkUnsupportedAuthType + } + }, + `Unsupported auth type for file link: ${context.authType}` + ); + } + return url; +} diff --git a/oaf/src/oaer/src/libs/webdav/operations/stat.ts b/oaf/src/oaer/src/libs/webdav/operations/stat.ts new file mode 100644 index 0000000..8ad7367 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/operations/stat.ts @@ -0,0 +1,32 @@ +import { parseStat, parseXML } from "../tools/dav"; +import { joinURL } from "../tools/url"; +import { encodePath } from "../tools/path"; +import { request, prepareRequestOptions } from "../request"; +import { handleResponseCode, processResponsePayload } from "../response"; +import { FileStat, ResponseDataDetailed, StatOptions, WebDAVClientContext } from "../types"; + +export async function getStat( + context: WebDAVClientContext, + filename: string, + options: StatOptions = {} +): Promise> { + const { details: isDetailed = false } = options; + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filename)), + method: "PROPFIND", + headers: { + Accept: "text/plain,application/xml", + Depth: "0" + }, + responseType: "text" + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); + const result = await parseXML(response.data as string); + const stat = parseStat(result, filename, isDetailed); + return processResponsePayload(response, stat, isDetailed); +} diff --git a/oaf/src/oaer/src/libs/webdav/request.ts b/oaf/src/oaer/src/libs/webdav/request.ts new file mode 100644 index 0000000..0f60421 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/request.ts @@ -0,0 +1,108 @@ +import axios from "axios"; +import { getPatcher } from "./compat/patcher"; +import { generateDigestAuthHeader, parseDigestAuth } from "./auth/digest"; +import { cloneShallow, merge } from "./tools/merge"; +import { mergeHeaders } from "./tools/headers"; +import { + RequestOptionsCustom, + RequestOptionsWithState, + RequestOptions, + Response, + WebDAVClientContext, + WebDAVMethodOptions +} from "./types"; + +function _request(requestOptions: RequestOptions) { + return getPatcher().patchInline( + "request", + (options: RequestOptions) => axios(options as any), + requestOptions + ); +} + +export function prepareRequestOptions( + requestOptions: RequestOptionsCustom | RequestOptionsWithState, + context: WebDAVClientContext, + userOptions: WebDAVMethodOptions +): RequestOptionsWithState { + const finalOptions = cloneShallow(requestOptions) as RequestOptionsWithState; + finalOptions.headers = mergeHeaders( + context.headers, + finalOptions.headers || {}, + userOptions.headers || {} + ); + if (typeof userOptions.data !== "undefined") { + finalOptions.data = userOptions.data; + } + if (context.httpAgent) { + finalOptions.httpAgent = context.httpAgent; + } + if (context.httpsAgent) { + finalOptions.httpsAgent = context.httpsAgent; + } + if (context.digest) { + finalOptions._digest = context.digest; + } + if (typeof context.withCredentials === "boolean") { + finalOptions.withCredentials = context.withCredentials; + } + if (context.maxContentLength) { + finalOptions.maxContentLength = context.maxContentLength; + } + if (context.maxBodyLength) { + finalOptions.maxBodyLength = context.maxBodyLength; + } + if (userOptions.hasOwnProperty("onUploadProgress")) { + finalOptions.onUploadProgress = userOptions["onUploadProgress"]; + } + // Take full control of all response status codes + finalOptions.validateStatus = () => true; + return finalOptions; +} + +export function request(requestOptions: RequestOptionsWithState): Promise { + // Client not configured for digest authentication + if (!requestOptions._digest) { + return _request(requestOptions); + } + + // Remove client's digest authentication object from request options + const _digest = requestOptions._digest; + delete requestOptions._digest; + + // If client is already using digest authentication, include the digest authorization header + if (_digest.hasDigestAuth) { + requestOptions = merge(requestOptions, { + headers: { + Authorization: generateDigestAuthHeader(requestOptions, _digest) + } + }); + } + + // Perform the request and handle digest authentication + return _request(requestOptions).then(function(response: Response) { + if (response.status == 401) { + _digest.hasDigestAuth = parseDigestAuth(response, _digest); + + if (_digest.hasDigestAuth) { + requestOptions = merge(requestOptions, { + headers: { + Authorization: generateDigestAuthHeader(requestOptions, _digest) + } + }); + + return _request(requestOptions).then(function(response2: Response) { + if (response2.status == 401) { + _digest.hasDigestAuth = false; + } else { + _digest.nc++; + } + return response2; + }); + } + } else { + _digest.nc++; + } + return response; + }); +} diff --git a/oaf/src/oaer/src/libs/webdav/response.ts b/oaf/src/oaer/src/libs/webdav/response.ts new file mode 100644 index 0000000..cca7caa --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/response.ts @@ -0,0 +1,46 @@ +import minimatch from "minimatch"; +import { + FileStat, + Response, + ResponseDataDetailed, + WebDAVClientContext, + WebDAVClientError +} from "./types"; + +export function createErrorFromResponse(response: Response, prefix: string = ""): Error { + const err: WebDAVClientError = new Error( + `${prefix}Invalid response: ${response.status} ${response.statusText}` + ) as WebDAVClientError; + err.status = response.status; + err.response = response; + return err; +} + +export function handleResponseCode(context: WebDAVClientContext, response: Response): Response { + const { status } = response; + if (status === 401 && context.digest) return response; + if (status >= 400) { + const err = createErrorFromResponse(response); + throw err; + } + return response; +} + +export function processGlobFilter(files: Array, glob: string): Array { + return files.filter(file => minimatch(file.filename, glob, { matchBase: true })); +} + +export function processResponsePayload( + response: Response, + data: T, + isDetailed: boolean = false +): ResponseDataDetailed | T { + return isDetailed + ? { + data, + headers: response.headers || {}, + status: response.status, + statusText: response.statusText + } + : data; +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/crypto.ts b/oaf/src/oaer/src/libs/webdav/tools/crypto.ts new file mode 100644 index 0000000..10bee66 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/crypto.ts @@ -0,0 +1,16 @@ +import md5 from "md5"; + +export function ha1Compute( + algorithm: string, + user: string, + realm: string, + pass: string, + nonce: string, + cnonce: string +): string { + const ha1 = md5(`${user}:${realm}:${pass}`) as string; + if (algorithm && algorithm.toLowerCase() === "md5-sess") { + return md5(`${ha1}:${nonce}:${cnonce}`) as string; + } + return ha1; +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/dav.ts b/oaf/src/oaer/src/libs/webdav/tools/dav.ts new file mode 100644 index 0000000..8267fa4 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/dav.ts @@ -0,0 +1,171 @@ +import path from "path-posix"; +import xmlParser from "fast-xml-parser"; +import nestedProp from "nested-property"; +import { decodeHTMLEntities } from "./encode"; +import { normalisePath } from "./path"; +import { + DAVResult, + DAVResultRaw, + DAVResultResponse, + DAVResultResponseProps, + DiskQuotaAvailable, + FileStat, + WebDAVClientError +} from "../types"; + +enum PropertyType { + Array = "array", + Object = "object", + Original = "original" +} + +function getPropertyOfType( + obj: Object, + prop: string, + type: PropertyType = PropertyType.Original +): any { + const val = nestedProp.get(obj, prop); + if (type === "array" && Array.isArray(val) === false) { + return [val]; + } else if (type === "object" && Array.isArray(val)) { + return val[0]; + } + return val; +} + +function normaliseResponse(response: any): DAVResultResponse { + const output = Object.assign({}, response); + nestedProp.set(output, "propstat", getPropertyOfType(output, "propstat", PropertyType.Object)); + nestedProp.set( + output, + "propstat.prop", + getPropertyOfType(output, "propstat.prop", PropertyType.Object) + ); + return output; +} + +function normaliseResult(result: DAVResultRaw): DAVResult { + const { multistatus } = result; + if (multistatus === "") { + return { + multistatus: { + response: [] + } + }; + } + if (!multistatus) { + throw new Error("Invalid response: No root multistatus found"); + } + const output: any = { + multistatus: Array.isArray(multistatus) ? multistatus[0] : multistatus + }; + nestedProp.set( + output, + "multistatus.response", + getPropertyOfType(output, "multistatus.response", PropertyType.Array) + ); + nestedProp.set( + output, + "multistatus.response", + nestedProp.get(output, "multistatus.response").map(response => normaliseResponse(response)) + ); + return output as DAVResult; +} + +export function parseXML(xml: string): Promise { + return new Promise(resolve => { + const result = xmlParser.parse(xml, { + arrayMode: false, + ignoreNameSpace: true + // // We don't use the processors here as decoding is done manually + // // later on - decoding early would break some path checks. + // attrValueProcessor: val => decodeHTMLEntities(decodeURIComponent(val)), + // tagValueProcessor: val => decodeHTMLEntities(decodeURIComponent(val)) + }); + resolve(normaliseResult(result)); + }); +} + +export function prepareFileFromProps( + props: DAVResultResponseProps, + rawFilename: string, + isDetailed: boolean = false +): FileStat { + // Last modified time, raw size, item type and mime + const { + getlastmodified: lastMod = null, + getcontentlength: rawSize = "0", + resourcetype: resourceType = null, + getcontenttype: mimeType = null, + getetag: etag = null + } = props; + const type = + resourceType && + typeof resourceType === "object" && + typeof resourceType.collection !== "undefined" + ? "directory" + : "file"; + const filename = decodeHTMLEntities(rawFilename); + const stat: FileStat = { + filename, + basename: path.basename(filename), + lastmod: lastMod, + size: parseInt(rawSize, 10), + type, + etag: typeof etag === "string" ? etag.replace(/"/g, "") : null + }; + if (type === "file") { + stat.mime = mimeType && typeof mimeType === "string" ? mimeType.split(";")[0] : ""; + } + if (isDetailed) { + stat.props = props; + } + return stat; +} + +export function parseStat( + result: DAVResult, + filename: string, + isDetailed: boolean = false +): FileStat { + let responseItem: DAVResultResponse = null; + try { + responseItem = result.multistatus.response[0]; + } catch (e) { + /* ignore */ + } + if (!responseItem) { + throw new Error("Failed getting item stat: bad response"); + } + const { + propstat: { prop: props, status: statusLine } + } = responseItem; + + // As defined in https://tools.ietf.org/html/rfc2068#section-6.1 + const [_, statusCodeStr, statusText] = statusLine.split(" ", 3); + const statusCode = parseInt(statusCodeStr, 10); + if (statusCode >= 400) { + const err: WebDAVClientError = new Error( + `Invalid response: ${statusCode} ${statusText}` + ) as WebDAVClientError; + err.status = statusCode; + throw err; + } + + const filePath = normalisePath(filename); + return prepareFileFromProps(props, filePath, isDetailed); +} + +export function translateDiskSpace(value: string | number): DiskQuotaAvailable { + switch (value.toString()) { + case "-3": + return "unlimited"; + case "-2": + /* falls-through */ + case "-1": + // -1 is non-computed + return "unknown"; + default: + return parseInt(value as string, 10); + } +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/encode.ts b/oaf/src/oaer/src/libs/webdav/tools/encode.ts new file mode 100644 index 0000000..9b3e34a --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/encode.ts @@ -0,0 +1,24 @@ +import { decode, encode } from "base-64"; + +declare var WEB: boolean; + +export function decodeHTMLEntities(text: string): string { + if (typeof WEB === "undefined") { + // Node + const he = require("he"); + return he.decode(text); + } else { + // Nasty browser way + const txt = document.createElement("textarea"); + txt.innerHTML = text; + return txt.value; + } +} + +export function fromBase64(text: string): string { + return decode(text); +} + +export function toBase64(text: string): string { + return encode(text); +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/headers.ts b/oaf/src/oaer/src/libs/webdav/tools/headers.ts new file mode 100644 index 0000000..65236f0 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/headers.ts @@ -0,0 +1,18 @@ +import { Headers } from "../types"; + +export function mergeHeaders(...headerPayloads: Headers[]): Headers { + if (headerPayloads.length === 0) return {}; + const headerKeys = {}; + return headerPayloads.reduce((output: Headers, headers: Headers) => { + Object.keys(headers).forEach(header => { + const lowerHeader = header.toLowerCase(); + if (headerKeys.hasOwnProperty(lowerHeader)) { + output[headerKeys[lowerHeader]] = headers[header]; + } else { + headerKeys[lowerHeader] = header; + output[header] = headers[header]; + } + }); + return output; + }, {}); +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/merge.ts b/oaf/src/oaer/src/libs/webdav/tools/merge.ts new file mode 100644 index 0000000..a92f708 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/merge.ts @@ -0,0 +1,62 @@ +export function cloneShallow(obj: T): T { + return isPlainObject(obj) + ? Object.assign({}, obj) + : Object.setPrototypeOf(Object.assign({}, obj), Object.getPrototypeOf(obj)); +} + +function isPlainObject(obj: Object | any): boolean { + if ( + typeof obj !== "object" || + obj === null || + Object.prototype.toString.call(obj) != "[object Object]" + ) { + // Not an object + return false; + } + if (Object.getPrototypeOf(obj) === null) { + return true; + } + let proto = obj; + // Find the prototype + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + return Object.getPrototypeOf(obj) === proto; +} + +export function merge(...args: Object[]) { + let output = null, + items = [...args]; + while (items.length > 0) { + const nextItem = items.shift(); + if (!output) { + output = cloneShallow(nextItem); + } else { + output = mergeObjects(output, nextItem); + } + } + return output; +} + +function mergeObjects(obj1: Object, obj2: Object): Object { + const output = cloneShallow(obj1); + Object.keys(obj2).forEach(key => { + if (!output.hasOwnProperty(key)) { + output[key] = obj2[key]; + return; + } + if (Array.isArray(obj2[key])) { + output[key] = Array.isArray(output[key]) + ? [...output[key], ...obj2[key]] + : [...obj2[key]]; + } else if (typeof obj2[key] === "object" && !!obj2[key]) { + output[key] = + typeof output[key] === "object" && !!output[key] + ? mergeObjects(output[key], obj2[key]) + : cloneShallow(obj2[key]); + } else { + output[key] = obj2[key]; + } + }); + return output; +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/path.ts b/oaf/src/oaer/src/libs/webdav/tools/path.ts new file mode 100644 index 0000000..bb786fc --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/path.ts @@ -0,0 +1,36 @@ +import { dirname } from "path-posix"; + +const SEP_PATH_POSIX = "__PATH_SEPARATOR_POSIX__"; +const SEP_PATH_WINDOWS = "__PATH_SEPARATOR_WINDOWS__"; + +export function encodePath(path) { + const replaced = path.replace(/\//g, SEP_PATH_POSIX).replace(/\\\\/g, SEP_PATH_WINDOWS); + const formatted = encodeURIComponent(replaced); + return formatted + .split(SEP_PATH_WINDOWS) + .join("\\\\") + .split(SEP_PATH_POSIX) + .join("/"); +} + +export function getAllDirectories(path: string): Array { + if (!path || path === "/") return []; + let currentPath = path; + const output: Array = []; + do { + output.push(currentPath); + currentPath = dirname(currentPath); + } while (currentPath && currentPath !== "/"); + return output; +} + +export function normalisePath(pathStr: string): string { + let normalisedPath = pathStr; + if (normalisedPath[0] !== "/") { + normalisedPath = "/" + normalisedPath; + } + if (/^.+\/$/.test(normalisedPath)) { + normalisedPath = normalisedPath.substr(0, normalisedPath.length - 1); + } + return normalisedPath; +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/quota.ts b/oaf/src/oaer/src/libs/webdav/tools/quota.ts new file mode 100644 index 0000000..4adf650 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/quota.ts @@ -0,0 +1,22 @@ +import { translateDiskSpace } from "./dav"; +import { DAVResult, DiskQuota } from "../types"; + +export function parseQuota(result: DAVResult): DiskQuota | null { + try { + const [responseItem] = result.multistatus.response; + const { + propstat: { + prop: { "quota-used-bytes": quotaUsed, "quota-available-bytes": quotaAvail } + } + } = responseItem; + return typeof quotaUsed !== "undefined" && typeof quotaAvail !== "undefined" + ? { + used: parseInt(quotaUsed, 10), + available: translateDiskSpace(quotaAvail) + } + : null; + } catch (err) { + /* ignore */ + } + return null; +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/size.ts b/oaf/src/oaer/src/libs/webdav/tools/size.ts new file mode 100644 index 0000000..f5b09f0 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/size.ts @@ -0,0 +1,22 @@ +import { Layerr } from "layerr"; +import { isArrayBuffer } from "../compat/arrayBuffer"; +import { isBuffer } from "../compat/buffer"; +import { BufferLike, ErrorCode } from "../types"; + +export function calculateDataLength(data: string | BufferLike): number { + if (isArrayBuffer(data)) { + return (data).byteLength; + } else if (isBuffer(data)) { + return (data).length; + } else if (typeof data === "string") { + return (data).length; + } + throw new Layerr( + { + info: { + code: ErrorCode.DataTypeNoLength + } + }, + "Cannot calculate data length: Invalid type" + ); +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/url.ts b/oaf/src/oaer/src/libs/webdav/tools/url.ts new file mode 100644 index 0000000..94ca9d4 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/url.ts @@ -0,0 +1,32 @@ +import URL from "url-parse"; +import _joinURL from "url-join"; +import { normalisePath } from "./path"; + +export function extractURLPath(fullURL: string): string { + const url = new URL(fullURL); + let urlPath = url.pathname; + if (urlPath.length <= 0) { + urlPath = "/"; + } + return normalisePath(urlPath); +} + +export function joinURL(...parts: Array): string { + return _joinURL( + parts.reduce((output, nextPart, partIndex) => { + if ( + partIndex === 0 || + nextPart !== "/" || + (nextPart === "/" && output[output.length - 1] !== "/") + ) { + output.push(nextPart); + } + return output; + }, []) + ); +} + +export function normaliseHREF(href: string): string { + const normalisedHref = href.replace(/^https?:\/\/[^\/]+/, ""); + return normalisedHref; +} diff --git a/oaf/src/oaer/src/libs/webdav/tools/xml.ts b/oaf/src/oaer/src/libs/webdav/tools/xml.ts new file mode 100644 index 0000000..ba2e588 --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/tools/xml.ts @@ -0,0 +1,55 @@ +import xmlParser, { j2xParser as XMLParser } from "fast-xml-parser"; + +export function generateLockXML(ownerHREF: string): string { + return getParser().parse( + namespace( + { + lockinfo: { + "@_xmlns:d": "DAV:", + lockscope: { + exclusive: {} + }, + locktype: { + write: {} + }, + owner: { + href: ownerHREF + } + } + }, + "d" + ) + ); +} + +function getParser(): XMLParser { + return new XMLParser({ + attributeNamePrefix: "@_", + format: true, + ignoreAttributes: false, + supressEmptyNode: true + }); +} + +function namespace(obj: T, ns: string): T { + const copy = { ...obj }; + for (const key in copy) { + if (copy[key] && typeof copy[key] === "object" && key.indexOf(":") === -1) { + copy[`${ns}:${key}`] = namespace(copy[key], ns); + delete copy[key]; + } else if (/^@_/.test(key) === false) { + copy[`${ns}:${key}`] = copy[key]; + delete copy[key]; + } + } + return copy; +} + +export function parseGenericResponse(xml: string): Object { + return xmlParser.parse(xml, { + arrayMode: false, + ignoreNameSpace: true, + parseAttributeValue: true, + parseNodeValue: true + }); +} diff --git a/oaf/src/oaer/src/libs/webdav/types.ts b/oaf/src/oaer/src/libs/webdav/types.ts new file mode 100644 index 0000000..e0d6c1e --- /dev/null +++ b/oaf/src/oaer/src/libs/webdav/types.ts @@ -0,0 +1,288 @@ +import Stream from "stream"; + +export type AuthHeader = string; + +export enum AuthType { + Digest = "digest", + None = "none", + Password = "password", + Token = "token" +} + +export type BufferLike = Buffer | ArrayBuffer; + +export interface CreateDirectoryOptions extends WebDAVMethodOptions { + recursive?: boolean; +} + +export interface CreateReadStreamOptions extends WebDAVMethodOptions { + callback?: (response: Response) => void; + range?: { + start: number; + end?: number; + }; +} + +export type CreateWriteStreamCallback = (response: Response) => void; + +export interface CreateWriteStreamOptions extends WebDAVMethodOptions { + overwrite?: boolean; +} + +export interface DAVResultResponse { + href: string; + propstat: { + prop: DAVResultResponseProps; + status: string; + }; +} + +export interface DAVResultResponseProps { + displayname: string; + resourcetype: { + collection?: boolean; + }; + getlastmodified?: string; + getetag?: string; + getcontentlength?: string; + getcontenttype?: string; + "quota-available-bytes"?: any; + "quota-used-bytes"?: string; +} + +export interface DAVResult { + multistatus: { + response: Array; + }; +} + +export interface DAVResultRawMultistatus { + response: DAVResultResponse | [DAVResultResponse]; +} + +export interface DAVResultRaw { + multistatus: "" | DAVResultRawMultistatus | [DAVResultRawMultistatus]; +} + +export interface DigestContext { + username: string; + password: string; + nc: number; + algorithm: string; + hasDigestAuth: boolean; + cnonce?: string; + nonce?: string; + realm?: string; + qop?: string; + opaque?: string; +} + +export interface DiskQuota { + used: number; + available: DiskQuotaAvailable; +} + +export type DiskQuotaAvailable = "unknown" | "unlimited" | number; + +export enum ErrorCode { + DataTypeNoLength = "data-type-no-length", + InvalidAuthType = "invalid-auth-type", + InvalidOutputFormat = "invalid-output-format", + LinkUnsupportedAuthType = "link-unsupported-auth" +} + +export interface FileStat { + filename: string; + basename: string; + lastmod: string; + size: number; + type: "file" | "directory"; + etag: string | null; + mime?: string; + props?: DAVResultResponseProps; +} + +export interface GetDirectoryContentsOptions extends WebDAVMethodOptions { + deep?: boolean; + details?: boolean; + glob?: string; +} + +export interface GetFileContentsOptions extends WebDAVMethodOptions { + details?: boolean; + format?: "binary" | "text"; +} + +export interface GetQuotaOptions extends WebDAVMethodOptions { + details?: boolean; +} + +export interface Headers { + [key: string]: string; +} + +export interface LockOptions extends WebDAVMethodOptions { + refreshToken?: string; + timeout?: string; +} + +export interface LockResponse { + serverTimeout: string; + token: string; +} + +export interface OAuthToken { + access_token: string; + token_type: string; + refresh_token?: string; +} + +export interface PutFileContentsOptions extends WebDAVMethodOptions { + contentLength?: boolean | number; + overwrite?: boolean; + onUploadProgress?: UploadProgressCallback; +} + +export type RequestDataPayload = string | Buffer | ArrayBuffer | { [key: string]: any }; + +interface RequestOptionsBase { + data?: RequestDataPayload; + headers?: Headers; + httpAgent?: any; + httpsAgent?: any; + maxBodyLength?: number; + maxContentLength?: number; + maxRedirects?: number; + method: string; + onUploadProgress?: UploadProgressCallback; + responseType?: string; + transformResponse?: Array<(value: any) => any>; + url?: string; + validateStatus?: (status: number) => boolean; + withCredentials?: boolean; +} + +export interface RequestOptionsCustom extends RequestOptionsBase {} + +export interface RequestOptions extends RequestOptionsBase { + url: string; +} + +export interface RequestOptionsWithState extends RequestOptions { + _digest?: DigestContext; +} + +export interface Response { + data: ResponseData; + status: number; + headers: Headers; + statusText: string; +} + +export type ResponseData = string | Buffer | ArrayBuffer | Object | Array; + +export interface ResponseDataDetailed { + data: T; + headers: Headers; + status: number; + statusText: string; +} + +export interface ResponseStatusValidator { + (status: number): boolean; +} + +export interface StatOptions extends WebDAVMethodOptions { + details?: boolean; +} + +export interface UploadProgress { + loaded: number; + total: number; +} + +export interface UploadProgressCallback { + (progress: UploadProgress): void; +} + +export interface WebDAVClient { + copyFile: (filename: string, destination: string) => Promise; + createDirectory: (path: string, options?: CreateDirectoryOptions) => Promise; + createReadStream: (filename: string, options?: CreateReadStreamOptions) => Stream.Readable; + createWriteStream: ( + filename: string, + options?: CreateWriteStreamOptions, + callback?: CreateWriteStreamCallback + ) => Stream.Writable; + customRequest: (path: string, requestOptions: RequestOptionsCustom) => Promise; + deleteFile: (filename: string) => Promise; + exists: (path: string) => Promise; + getDirectoryContents: ( + path: string, + options?: GetDirectoryContentsOptions + ) => Promise | ResponseDataDetailed>>; + getFileContents: ( + filename: string, + options?: GetFileContentsOptions + ) => Promise>; + getFileDownloadLink: (filename: string) => string; + getFileUploadLink: (filename: string) => string; + getHeaders: () => Headers; + getQuota: ( + options?: GetQuotaOptions + ) => Promise>; + lock: (path: string, options?: LockOptions) => Promise; + moveFile: (filename: string, destinationFilename: string) => Promise; + putFileContents: ( + filename: string, + data: string | BufferLike | Stream.Readable, + options?: PutFileContentsOptions + ) => Promise; + setHeaders: (headers: Headers) => void; + stat: ( + path: string, + options?: StatOptions + ) => Promise>; + unlock: (path: string, token: string, options?: WebDAVMethodOptions) => Promise; +} + +export interface WebDAVClientContext { + authType: AuthType; + contactHref: string; + digest?: DigestContext; + headers: Headers; + httpAgent?: any; + httpsAgent?: any; + maxBodyLength?: number; + maxContentLength?: number; + password?: string; + remotePath: string; + remoteURL: string; + token?: OAuthToken; + username?: string; + withCredentials?: boolean; +} + +export interface WebDAVClientError extends Error { + status?: number; + response?: Response; +} + +export interface WebDAVClientOptions { + authType?: AuthType; + contactHref?: string; + headers?: Headers; + httpAgent?: any; + httpsAgent?: any; + maxBodyLength?: number; + maxContentLength?: number; + password?: string; + token?: OAuthToken; + username?: string; + withCredentials?: boolean; +} + +export interface WebDAVMethodOptions { + data?: RequestDataPayload; + headers?: Headers; +} diff --git a/oaf/src/oaer/src/main.vue b/oaf/src/oaer/src/main.vue index 74ba53f..a7c3a55 100644 --- a/oaf/src/oaer/src/main.vue +++ b/oaf/src/oaer/src/main.vue @@ -58,7 +58,6 @@ import {decode} from 'js-base64' import {api, Cfg} from './api' import evt from './evt' import {modelsApp, modelsUser} from './models' -console.log('init oaer') let shown = ref(false) let emits = defineEmits<{ @@ -71,7 +70,6 @@ let props = withDefaults(defineProps<{ isDark: false, }) onMounted(() => { - console.log('mount') fetchUserData() }) @@ -81,13 +79,11 @@ let self = ref({} as modelsApp) let token = computed(() => Cfg.token.value) watch(token, () => { - console.log('sync token') fetchUserData() }) function fetchUserData() { let token = Cfg.token.value?.split('.') - console.log(token) if (!token || token.length !== 3) { return false } diff --git a/oaf/tsconfig.json b/oaf/tsconfig.json index 855da46..3474565 100644 --- a/oaf/tsconfig.json +++ b/oaf/tsconfig.json @@ -10,6 +10,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "baseUrl": ".", + "skipLibCheck": true, "paths": { "@/*": [ "./src/*" @@ -27,6 +28,6 @@ "src/**/*.vue" ], "exclude": [ - "src/libs/webdav" + "src/libs/webdav/**" ] }