@filebox/webdav-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/context.js +37 -0
- package/index.js +88 -0
- package/operations/commands.js +31 -0
- package/operations/copy.js +37 -0
- package/operations/delete.js +12 -0
- package/operations/get.js +34 -0
- package/operations/head.js +23 -0
- package/operations/lock.js +1 -0
- package/operations/mkcol.js +14 -0
- package/operations/move.js +46 -0
- package/operations/not-implemented.js +10 -0
- package/operations/options.js +17 -0
- package/operations/post.js +10 -0
- package/operations/propfind.js +323 -0
- package/operations/proppatch.js +5 -0
- package/operations/put.js +66 -0
- package/operations/shared.js +26 -0
- package/operations/unlock.js +1 -0
- package/package.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FileBox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @filebox/webdav-server
|
|
2
|
+
|
|
3
|
+
FileBox 的 WebDAV 服务适配包,用来把 FileBox 驱动暴露为 WebDAV 请求处理器。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @filebox/webdav-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 使用
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const { WebDAVServer } = require("@filebox/webdav-server");
|
|
15
|
+
|
|
16
|
+
const server = new WebDAVServer({
|
|
17
|
+
auth: (user, password) => user === "admin" && password === "password",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const response = await server.request(request, {
|
|
21
|
+
driver,
|
|
22
|
+
base: "/webdav",
|
|
23
|
+
temp: "/tmp/filebox",
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
驱动需要提供 WebDAV 操作中用到的文件能力,例如 `stat`、`list`、`download`、`upload`、`mkdir`、`remove`、`rename`、`copy` 和 `move`。
|
|
28
|
+
|
|
29
|
+
## License
|
|
30
|
+
|
|
31
|
+
MIT
|
package/context.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const createContext = (req, base, allows, temp) => {
|
|
2
|
+
const authorization = req.headers?.authorization?.split(" ")[1];
|
|
3
|
+
|
|
4
|
+
const path = new URL(req.url, `http://${req.headers.host}`).pathname;
|
|
5
|
+
const ctx = {
|
|
6
|
+
req: req,
|
|
7
|
+
depth: req.headers?.depth || 0,
|
|
8
|
+
method: (req.method || "").toLowerCase(),
|
|
9
|
+
path: path.replace(base, "").replace("/k", ""), // 临时这么做
|
|
10
|
+
base,
|
|
11
|
+
config: {
|
|
12
|
+
temp,
|
|
13
|
+
},
|
|
14
|
+
auth: { user: undefined, pass: undefined },
|
|
15
|
+
allows,
|
|
16
|
+
get(field) {
|
|
17
|
+
const req = this.req;
|
|
18
|
+
switch ((field = field.toLowerCase())) {
|
|
19
|
+
case "referer":
|
|
20
|
+
case "referrer":
|
|
21
|
+
return req.headers.referrer || req.headers.referer || "";
|
|
22
|
+
default:
|
|
23
|
+
return req.headers[field] || "";
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (authorization) {
|
|
29
|
+
const pairs = Buffer.from(authorization, "base64")
|
|
30
|
+
.toString("utf8")
|
|
31
|
+
.split(":");
|
|
32
|
+
ctx.auth = { user: pairs[0], pass: pairs[1] };
|
|
33
|
+
}
|
|
34
|
+
return ctx;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
module.exports = createContext;
|
package/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const Commands = require("./operations/commands");
|
|
2
|
+
const createContext = require("./context");
|
|
3
|
+
|
|
4
|
+
const StatusCodes = {
|
|
5
|
+
200: "OK",
|
|
6
|
+
201: "Created",
|
|
7
|
+
204: "No Content",
|
|
8
|
+
207: "Multi Status",
|
|
9
|
+
302: "Moved Temporarily",
|
|
10
|
+
401: "Unauthorized",
|
|
11
|
+
403: "Forbidden",
|
|
12
|
+
404: "Not Found",
|
|
13
|
+
409: "Conflict",
|
|
14
|
+
423: "Locked",
|
|
15
|
+
500: "Internal Server Error",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const VirtualDriver = {
|
|
19
|
+
async get() {
|
|
20
|
+
return {
|
|
21
|
+
status: "200",
|
|
22
|
+
body: "Hello World!",
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
// 其他方法...
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class WebDAVServer {
|
|
32
|
+
constructor(
|
|
33
|
+
{ driver, base, redirect, auth } = { redirect: false, auth: () => true },
|
|
34
|
+
) {
|
|
35
|
+
this.methods = {};
|
|
36
|
+
this.driver = driver || VirtualDriver;
|
|
37
|
+
this.base = base || "";
|
|
38
|
+
this.auth = auth;
|
|
39
|
+
this.config = { redirect };
|
|
40
|
+
const commands = Commands;
|
|
41
|
+
for (const k in commands) {
|
|
42
|
+
if (k === "NotImplemented") {
|
|
43
|
+
this.unknownMethod = commands[k];
|
|
44
|
+
} else {
|
|
45
|
+
this.methods[k.toLowerCase()] = commands[k];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.allows = Object.keys(this.methods).map((i) => i.toUpperCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async request(req, options) {
|
|
52
|
+
const ctx = createContext(
|
|
53
|
+
req,
|
|
54
|
+
options?.base || this.base,
|
|
55
|
+
this.allows,
|
|
56
|
+
options.temp,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
!(ctx.method == "options" && !ctx.path) &&
|
|
61
|
+
!this?.auth(ctx.auth.user, ctx.auth.pass)
|
|
62
|
+
) {
|
|
63
|
+
return {
|
|
64
|
+
headers: {
|
|
65
|
+
"X-WebDAV-Status": `401 ${StatusCodes[401]}`,
|
|
66
|
+
"www-Authenticate": 'Basic realm="Restricted"',
|
|
67
|
+
},
|
|
68
|
+
status: "401",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ctx.driver = this.driver;
|
|
73
|
+
ctx.driver = options.driver;
|
|
74
|
+
ctx.config = { ...this.config, temp: options.temp };
|
|
75
|
+
const method = this.methods[ctx.method] || this.unknownMethod;
|
|
76
|
+
const res = await method(ctx);
|
|
77
|
+
res.headers = res.headers ?? {};
|
|
78
|
+
if (res.status) {
|
|
79
|
+
// res.headers["X-WebDAV-Status"] =
|
|
80
|
+
// res.status + " " + StatusCodes[res.status];
|
|
81
|
+
}
|
|
82
|
+
return res;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
WebDAVServer,
|
|
88
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const Get = require("./get");
|
|
2
|
+
const Put = require("./put");
|
|
3
|
+
const Post = require("./post");
|
|
4
|
+
const Head = require("./head");
|
|
5
|
+
const Move = require("./move");
|
|
6
|
+
const Lock = require("./lock");
|
|
7
|
+
const Copy = require("./copy");
|
|
8
|
+
const Mkcol = require("./mkcol");
|
|
9
|
+
const Unlock = require("./unlock");
|
|
10
|
+
const Delete = require("./delete");
|
|
11
|
+
const Options = require("./options");
|
|
12
|
+
const Propfind = require("./propfind");
|
|
13
|
+
const Proppatch = require("./proppatch");
|
|
14
|
+
const NotImplemented = require("./not-implemented");
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
NotImplemented,
|
|
18
|
+
Proppatch,
|
|
19
|
+
Propfind,
|
|
20
|
+
Options,
|
|
21
|
+
Delete,
|
|
22
|
+
// Unlock,
|
|
23
|
+
Mkcol,
|
|
24
|
+
Copy,
|
|
25
|
+
// Lock,
|
|
26
|
+
Move,
|
|
27
|
+
Head,
|
|
28
|
+
// Post,
|
|
29
|
+
Put,
|
|
30
|
+
Get,
|
|
31
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module.exports = async (ctx) => {
|
|
2
|
+
try {
|
|
3
|
+
const dst = new URL(ctx.req.headers?.destination).pathname.replace(
|
|
4
|
+
ctx.base,
|
|
5
|
+
"",
|
|
6
|
+
);
|
|
7
|
+
const src = ctx.path;
|
|
8
|
+
if (src === dst) {
|
|
9
|
+
return { status: "403" };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const stat = await ctx.driver.stat(src);
|
|
13
|
+
if (!stat || !stat.file) {
|
|
14
|
+
return { status: "404" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const data = await ctx.driver.download(src);
|
|
18
|
+
if (!data || !data.length) {
|
|
19
|
+
return { status: "404" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { basename, dirname } = require("pathe");
|
|
23
|
+
const upath = dirname(dst);
|
|
24
|
+
const name = basename(dst);
|
|
25
|
+
const resp = await fetch(data[0].url);
|
|
26
|
+
await ctx.driver.upload(upath, {
|
|
27
|
+
name,
|
|
28
|
+
size: parseInt(resp.headers.get("content-length") || "0"),
|
|
29
|
+
data: resp.body,
|
|
30
|
+
mimeType: stat.mime || "application/octet-stream",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return { status: "201" };
|
|
34
|
+
} catch (_) {
|
|
35
|
+
return { status: "502" };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
async function loadProxy() {
|
|
2
|
+
const proxy = await import("@filebox/proxy");
|
|
3
|
+
return proxy.default || proxy;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
module.exports = async (ctx) => {
|
|
7
|
+
try {
|
|
8
|
+
const decodedPath = decodeURIComponent(ctx.path);
|
|
9
|
+
const stat = await ctx.driver.stat(decodedPath);
|
|
10
|
+
if (!stat || !stat.file) {
|
|
11
|
+
return {
|
|
12
|
+
status: "405",
|
|
13
|
+
body: "405 Method not allowed",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const data = await ctx.driver.download(decodedPath);
|
|
17
|
+
if (!data || !data.length || !data[0].url) {
|
|
18
|
+
return {
|
|
19
|
+
status: "404",
|
|
20
|
+
body: "file not found",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const proxy = await loadProxy();
|
|
24
|
+
return proxy(ctx.req, {
|
|
25
|
+
...data[0],
|
|
26
|
+
fileName: decodedPath,
|
|
27
|
+
});
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return {
|
|
30
|
+
status: "500",
|
|
31
|
+
body: "Internal Server Error",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = async (ctx) => {
|
|
2
|
+
try {
|
|
3
|
+
console.log(ctx.path, "head", 22222);
|
|
4
|
+
const stat = ctx.driver.stat(ctx.path);
|
|
5
|
+
const headers = {
|
|
6
|
+
"Content-Type": "application/octet-stream",
|
|
7
|
+
"Content-Length": stat.size,
|
|
8
|
+
"Last-Modified": stat.mtime,
|
|
9
|
+
"Accept-Ranges": "bytes",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
status: "200",
|
|
14
|
+
headers: headers,
|
|
15
|
+
body: null,
|
|
16
|
+
};
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.log(error);
|
|
19
|
+
return {
|
|
20
|
+
status: "404",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const { basename, dirname } = require("pathe");
|
|
2
|
+
|
|
3
|
+
module.exports = async (ctx) => {
|
|
4
|
+
try {
|
|
5
|
+
const dst = new URL(ctx.req.headers?.destination).pathname.replace(
|
|
6
|
+
ctx.base,
|
|
7
|
+
"",
|
|
8
|
+
);
|
|
9
|
+
const src = ctx.path;
|
|
10
|
+
|
|
11
|
+
if (!dst) {
|
|
12
|
+
return {
|
|
13
|
+
status: "400",
|
|
14
|
+
body: "Missing source or destination URI",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const normalizedSrc = src.endsWith("/") ? src.slice(0, -1) : src;
|
|
19
|
+
const normalizedDst = dst.endsWith("/") ? dst.slice(0, -1) : dst;
|
|
20
|
+
|
|
21
|
+
if (normalizedSrc === normalizedDst) {
|
|
22
|
+
return { status: "403" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isRename =
|
|
26
|
+
normalizedSrc.substring(0, normalizedSrc.lastIndexOf("/")) ===
|
|
27
|
+
normalizedDst.substring(0, normalizedDst.lastIndexOf("/"));
|
|
28
|
+
|
|
29
|
+
if (isRename) {
|
|
30
|
+
// 处理重命名
|
|
31
|
+
await ctx.driver?.rename(normalizedSrc, basename(normalizedDst));
|
|
32
|
+
} else {
|
|
33
|
+
// 处理移动
|
|
34
|
+
await ctx.driver?.move(normalizedSrc, dirname(normalizedDst));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
status: "201",
|
|
39
|
+
};
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return {
|
|
42
|
+
status: "502",
|
|
43
|
+
body: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module.exports = async (ctx) => {
|
|
2
|
+
const dav = [1];
|
|
3
|
+
|
|
4
|
+
if (ctx.allows?.includes("LOCK")) {
|
|
5
|
+
dav.push(2);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
headers: {
|
|
10
|
+
// For Microsoft clients
|
|
11
|
+
"MS-Author-Via": "DAV",
|
|
12
|
+
DAV: dav.join(", "),
|
|
13
|
+
Allow: ctx.allows?.join(", ") || "",
|
|
14
|
+
},
|
|
15
|
+
status: "200",
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
const parseXML = require("./shared");
|
|
2
|
+
const xml2js = require("xml2js");
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROPS = [
|
|
5
|
+
"displayname",
|
|
6
|
+
"getcontentlength",
|
|
7
|
+
"resourcetype",
|
|
8
|
+
"getcontenttype",
|
|
9
|
+
"creationdate",
|
|
10
|
+
"getlastmodified",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse props from webdav request
|
|
15
|
+
*
|
|
16
|
+
* @param {object} [data]
|
|
17
|
+
* @return {object|boolean}
|
|
18
|
+
*/
|
|
19
|
+
// const propParse = (data) => {
|
|
20
|
+
// if (!data) return {
|
|
21
|
+
// ns: { prefix: 'D', uri: 'DAV:' },
|
|
22
|
+
// prop: [...DEFAULT_PROPS]
|
|
23
|
+
// }
|
|
24
|
+
// let prop = [...DEFAULT_PROPS]
|
|
25
|
+
// const prefix = Object.keys(data.propfind.$).find(i => i.startsWith('xmlns:'))?.split(':')[1] || ''
|
|
26
|
+
// const uri = data.propfind.$?.[`xmlns${prefix ? `:${prefix}` : ''}`] || ''
|
|
27
|
+
// if (data.propfind.hasOwnProperty('prop')) {
|
|
28
|
+
// prop = Object.keys(data.propfind.prop)
|
|
29
|
+
// }
|
|
30
|
+
// return { ns: { prefix, uri }, prop }
|
|
31
|
+
// }
|
|
32
|
+
|
|
33
|
+
const propParse = (data) => {
|
|
34
|
+
if (!data) {
|
|
35
|
+
return {
|
|
36
|
+
ns: { prefix: "D", uri: "DAV:" },
|
|
37
|
+
prop: [...DEFAULT_PROPS],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let prop = [...DEFAULT_PROPS];
|
|
42
|
+
const ns = { prefix: "D", uri: "DAV:" };
|
|
43
|
+
|
|
44
|
+
if (data.propfind.hasOwnProperty("prop")) {
|
|
45
|
+
if (data.propfind.prop.hasOwnProperty("allprop")) {
|
|
46
|
+
return {
|
|
47
|
+
ns,
|
|
48
|
+
prop: [...DEFAULT_PROPS],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
prop = Object.keys(data.propfind.prop);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ns, prop };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create webdav responese xml by data and props options
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [data] file data
|
|
62
|
+
* @param {object} [options]
|
|
63
|
+
* @param {object} [options.props] Available props
|
|
64
|
+
* @param {object} [options.path] Current folder path
|
|
65
|
+
* @param {object} [options.ns]
|
|
66
|
+
* @return {string} XML string
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
// const convData = (files, options) => {
|
|
70
|
+
// const {
|
|
71
|
+
// path,
|
|
72
|
+
// base = "",
|
|
73
|
+
// depth,
|
|
74
|
+
// prop,
|
|
75
|
+
// ns: { prefix, uri },
|
|
76
|
+
// } = options;
|
|
77
|
+
// console.log(options, 2222);
|
|
78
|
+
// console.log(files, 222);
|
|
79
|
+
// return files.map((file) => {
|
|
80
|
+
// const item = {};
|
|
81
|
+
// for (const key of prop) {
|
|
82
|
+
// item[key] = file.name
|
|
83
|
+
// .replace(/&/g, "&")
|
|
84
|
+
// .replace(/</g, "<")
|
|
85
|
+
// .replace(/>/g, ">")
|
|
86
|
+
// .replace(/"/g, """)
|
|
87
|
+
// .replace(/'/g, "'");
|
|
88
|
+
|
|
89
|
+
// if (key == "getcontentlength") {
|
|
90
|
+
// item[key] = parseInt(file.size || 0);
|
|
91
|
+
// } else if (key == "resourcetype") {
|
|
92
|
+
// item[key] = file.type == "folder" ? { collection: "" } : "";
|
|
93
|
+
// // } else if (key == 'getcontenttype') {
|
|
94
|
+
// // item[key] = file.mime
|
|
95
|
+
// } else if (key == "creationdate" && file.ctime) {
|
|
96
|
+
// item[key] = new Date(file.ctime).toUTCString();
|
|
97
|
+
// } else if (key == "getlastmodified" && file.lastModifiedDateTime) {
|
|
98
|
+
// item[key] = new Date(file.lastModifiedDateTime).toUTCString();
|
|
99
|
+
// }
|
|
100
|
+
// }
|
|
101
|
+
|
|
102
|
+
// const href = (
|
|
103
|
+
// base +
|
|
104
|
+
// path +
|
|
105
|
+
// (depth == "0" ? "" : "/" + encodeURIComponent(file.name))
|
|
106
|
+
// ).replace(/\/{2,}/g, "/");
|
|
107
|
+
// //if (file.type == 'file' && file.download_url) href = file.download_url
|
|
108
|
+
// return {
|
|
109
|
+
// href,
|
|
110
|
+
// propstat: {
|
|
111
|
+
// status: "HTTP/1.1 200 OK",
|
|
112
|
+
// prop: item,
|
|
113
|
+
// },
|
|
114
|
+
// };
|
|
115
|
+
// });
|
|
116
|
+
// };
|
|
117
|
+
|
|
118
|
+
const convData = (files, options) => {
|
|
119
|
+
const {
|
|
120
|
+
path,
|
|
121
|
+
base = "",
|
|
122
|
+
depth,
|
|
123
|
+
prop,
|
|
124
|
+
ns: { prefix, uri },
|
|
125
|
+
} = options;
|
|
126
|
+
// console.log(prop, 1111)
|
|
127
|
+
files = files.data || files.list || files;
|
|
128
|
+
return files.map((file) => {
|
|
129
|
+
const item = {};
|
|
130
|
+
for (const key of prop) {
|
|
131
|
+
// Encode special characters for XML for displayname property
|
|
132
|
+
if (key === "displayname") {
|
|
133
|
+
item[key] = file.name
|
|
134
|
+
.replace(/&/g, "&")
|
|
135
|
+
.replace(/</g, "<")
|
|
136
|
+
.replace(/>/g, ">")
|
|
137
|
+
.replace(/"/g, """)
|
|
138
|
+
.replace(/'/g, "'");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Add the file size
|
|
142
|
+
if (key === "getcontentlength") {
|
|
143
|
+
item[key] = file.byte || 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Add the resource type
|
|
147
|
+
if (key === "resourcetype") {
|
|
148
|
+
item[key] = file.type === "folder" ? { collection: "" } : "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add the content type
|
|
152
|
+
if (key == "getcontenttype") {
|
|
153
|
+
item[key] = file.mime;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (key === "getetag") {
|
|
157
|
+
const stamp =
|
|
158
|
+
file.etag || file.hash || file.mtime || file.updatedAt || "";
|
|
159
|
+
item[key] =
|
|
160
|
+
`"${Buffer.from(`${file.name || ""}:${file.byte || 0}:${stamp}`).toString("base64url")}"`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Add the creation date
|
|
164
|
+
if (key === "creationdate" && file.ctime) {
|
|
165
|
+
item[key] = new Date(file.ctime).toUTCString();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Add the last modified date
|
|
169
|
+
if (key === "getlastmodified" && file.mtime) {
|
|
170
|
+
item[key] = new Date(file.mtime).toUTCString();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Set the available quota bytes to indicate unlimited
|
|
174
|
+
if (key === "quota-available-bytes") {
|
|
175
|
+
item[key] = -1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Set the used quota bytes to indicate unlimited
|
|
179
|
+
if (key === "quota-used-bytes") {
|
|
180
|
+
item[key] = -1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Construct the href.
|
|
185
|
+
// The collection itself uses base+path; its children append /name.
|
|
186
|
+
// Collections get a trailing slash (RFC 4918 §5.2).
|
|
187
|
+
const isSelf = file._self === true;
|
|
188
|
+
let href = (
|
|
189
|
+
isSelf ? base + path : base + path + "/" + encodeURIComponent(file.name)
|
|
190
|
+
).replace(/\/{2,}/g, "/");
|
|
191
|
+
if (file.type === "folder" && !href.endsWith("/")) href += "/";
|
|
192
|
+
// Return the WebDAV propstat object
|
|
193
|
+
return {
|
|
194
|
+
href,
|
|
195
|
+
propstat: {
|
|
196
|
+
status: "HTTP/1.1 200 OK",
|
|
197
|
+
prop: item,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const fixNs = (data, prefix) => {
|
|
204
|
+
if (!prefix) return data;
|
|
205
|
+
Object.keys(data).forEach((key) => {
|
|
206
|
+
const val = data[key];
|
|
207
|
+
if (key != "$" && prefix) {
|
|
208
|
+
if (Array.isArray(val) || typeof val == "object") {
|
|
209
|
+
fixNs(val, prefix);
|
|
210
|
+
}
|
|
211
|
+
delete data[key];
|
|
212
|
+
data[`${prefix}:${key}`] = val;
|
|
213
|
+
} else {
|
|
214
|
+
if (val.xmlns) {
|
|
215
|
+
val[`xmlns:${prefix}`] = val.xmlns;
|
|
216
|
+
delete val.xmlns;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
return data;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const createXML = (data, options) => {
|
|
224
|
+
const {
|
|
225
|
+
ns: { prefix, uri },
|
|
226
|
+
} = options;
|
|
227
|
+
|
|
228
|
+
const obj = {
|
|
229
|
+
multistatus: {
|
|
230
|
+
response: convData(data || [], options),
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
if (uri) {
|
|
234
|
+
obj.multistatus.$ = {
|
|
235
|
+
xmlns: uri,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const builder = new xml2js.Builder({
|
|
240
|
+
renderOpts: { pretty: false },
|
|
241
|
+
xmldec: { version: "1.0", encoding: "UTF-8" },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const xml = builder.buildObject(fixNs(obj, prefix));
|
|
245
|
+
return xml;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
module.exports = async (ctx) => {
|
|
249
|
+
const options = Object.assign(
|
|
250
|
+
{
|
|
251
|
+
path: ctx.path,
|
|
252
|
+
base: ctx.base,
|
|
253
|
+
depth: ctx.depth,
|
|
254
|
+
},
|
|
255
|
+
propParse(await parseXML(ctx.req)),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
let data = {};
|
|
259
|
+
data.item = await ctx.driver.stat(ctx.path);
|
|
260
|
+
// console.log(data.item);
|
|
261
|
+
if (ctx.depth == "1") {
|
|
262
|
+
try {
|
|
263
|
+
data.files = await ctx.driver.list(ctx.path);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return { status: "500" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!data) return { status: "404" };
|
|
270
|
+
|
|
271
|
+
if (data.error) {
|
|
272
|
+
if (data.error.code == 401) {
|
|
273
|
+
// Windows seems to require this being the last header sent
|
|
274
|
+
// (changed according to PECL bug #3138)
|
|
275
|
+
return {
|
|
276
|
+
headers: {
|
|
277
|
+
"WWW-Authenticate": `Basic realm="ShareList WebDAV"`,
|
|
278
|
+
},
|
|
279
|
+
status: "401",
|
|
280
|
+
};
|
|
281
|
+
} else {
|
|
282
|
+
return {
|
|
283
|
+
status: "404",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
//return itself
|
|
289
|
+
// Mark the requested resource as "self" so its href is built as base+path
|
|
290
|
+
// (children append /name). Depth:1 must include the collection itself
|
|
291
|
+
// followed by its members (RFC 4918 §9.1).
|
|
292
|
+
const selfItem = Object.assign({}, data.item, { _self: true });
|
|
293
|
+
if (ctx.depth == "0") {
|
|
294
|
+
return {
|
|
295
|
+
status: "207",
|
|
296
|
+
headers: {
|
|
297
|
+
"content-type": 'text/xml; charset="utf-8"',
|
|
298
|
+
},
|
|
299
|
+
body: createXML([selfItem], options),
|
|
300
|
+
};
|
|
301
|
+
} else if (ctx.depth == "1") {
|
|
302
|
+
const listResult = data.files;
|
|
303
|
+
const children = Array.isArray(listResult)
|
|
304
|
+
? listResult
|
|
305
|
+
: Array.isArray(listResult && listResult.data)
|
|
306
|
+
? listResult.data
|
|
307
|
+
: Array.isArray(listResult && listResult.list)
|
|
308
|
+
? listResult.list
|
|
309
|
+
: [];
|
|
310
|
+
return {
|
|
311
|
+
status: "207",
|
|
312
|
+
headers: {
|
|
313
|
+
// "content-type": 'text/xml; charset="utf-8"',
|
|
314
|
+
"content-type": 'application/xml; charset="utf-8"',
|
|
315
|
+
},
|
|
316
|
+
body: createXML([selfItem].concat(children), options),
|
|
317
|
+
};
|
|
318
|
+
} else if (ctx.depth == "infinity") {
|
|
319
|
+
return {
|
|
320
|
+
status: "404",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const { basename, dirname } = require("pathe");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
module.exports = async (ctx) => {
|
|
6
|
+
try {
|
|
7
|
+
const rawPath = decodeURIComponent(ctx.path);
|
|
8
|
+
const name = basename(rawPath);
|
|
9
|
+
const upath = dirname(rawPath);
|
|
10
|
+
|
|
11
|
+
const contentLength = parseInt(ctx.req.headers["content-length"] || 0);
|
|
12
|
+
const mimeType =
|
|
13
|
+
ctx.req.headers["content-type"] || "application/octet-stream";
|
|
14
|
+
|
|
15
|
+
const tempDir = ctx.config?.temp;
|
|
16
|
+
if (!tempDir) {
|
|
17
|
+
return { status: "500", body: "temp dir not configured" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tempFile = path.join(tempDir, `webdav-upload-${Date.now()}-${name}`);
|
|
21
|
+
const uploadStream =
|
|
22
|
+
ctx.req._webdavStream ||
|
|
23
|
+
(ctx.req.readable && !ctx.req.readableEnded ? ctx.req : null);
|
|
24
|
+
|
|
25
|
+
if (uploadStream && contentLength > 0) {
|
|
26
|
+
await new Promise((resolve, reject) => {
|
|
27
|
+
const ws = fs.createWriteStream(tempFile);
|
|
28
|
+
uploadStream.pipe(ws);
|
|
29
|
+
ws.on("finish", resolve);
|
|
30
|
+
ws.on("error", reject);
|
|
31
|
+
uploadStream.on("error", reject);
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
fs.writeFileSync(tempFile, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tempStat = fs.statSync(tempFile);
|
|
38
|
+
|
|
39
|
+
if (tempStat.size === 0 && contentLength > 0) {
|
|
40
|
+
return { status: "502", body: "upload failed: empty file" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await ctx.driver.upload(upath, {
|
|
45
|
+
name,
|
|
46
|
+
size: tempStat.size,
|
|
47
|
+
absolutePath: tempFile,
|
|
48
|
+
mimeType,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (result && typeof result.upload === "function") {
|
|
52
|
+
await result.upload();
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { status: "502", body: String(error) };
|
|
56
|
+
} finally {
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(tempFile);
|
|
59
|
+
} catch (_) {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { status: "200" };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { status: "502", body: String(err?.message || err) };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { parseStringPromise, processors } = require("xml2js");
|
|
2
|
+
|
|
3
|
+
const parseBody = (req, charset) => {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const data = [];
|
|
6
|
+
const stream = req._webdavStream || req;
|
|
7
|
+
stream
|
|
8
|
+
.on("data", (chunk) => {
|
|
9
|
+
data.push(chunk);
|
|
10
|
+
})
|
|
11
|
+
.on("error", reject)
|
|
12
|
+
.on("end", () => resolve(Buffer.concat(data).toString(charset)));
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const parseXML = async (req) => {
|
|
17
|
+
const txt = await parseBody(req);
|
|
18
|
+
if (!txt.trim()) return null;
|
|
19
|
+
return await parseStringPromise(txt, {
|
|
20
|
+
// explicitChildren: true,
|
|
21
|
+
explicitArray: false,
|
|
22
|
+
tagNameProcessors: [processors.stripPrefix],
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = parseXML;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@filebox/webdav-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "WebDAV server adapter for FileBox drivers",
|
|
5
|
+
"main": "./index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"require": "./index.js",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"context.js",
|
|
15
|
+
"operations",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@filebox/proxy": "^1.0.0",
|
|
25
|
+
"pathe": "^1.1.2",
|
|
26
|
+
"xml2js": "^0.6.0"
|
|
27
|
+
}
|
|
28
|
+
}
|