树洞剪藏#剪藏#树洞剪藏

放弃自建Alist后,我如何用Koofr和Deno打造稳定高速的 WebDAV - Lainbo

2025 年 10 月 15 日1 分钟
分享Twitter / XTelegram微博

同步来源:树洞剪藏 源站剪藏 ID:606 原文地址:https://lainbo.dev/koofr-deno?locale=zh

打开原文 · 打开源站剪藏快照


放弃自建Alist后,我如何用Koofr和Deno打造稳定高速的 WebDAV - Lainbo

set 限制解除

Lainbo's Blog

关注 If you’re nothing without the suit, then you shouldn't have it. 31 关注者 0 正在关注 赞助

首页 归档

连接

放弃自建Alist后,我如何用Koofr和Deno打造稳定高速的 WebDAV

2025年6月12日 #工具应用 1054

最近,知名的开源项目 Alist 发生了一系列风波,引发了社区对供应链安全的广泛担忧。这件事对我个人而言,不仅是一个安全警示,更是一个契机,让我重新审视了长期以来依赖自建服务的策略。

我使用 Alist, 主要看重的是其强大的 WebDAV 服务和多用户权限功能,而不是他能挂一堆网盘上去 ,它让我在自己的服务器上轻松地搭建起了个人数据同步中心。但这次事件让我意识到,** 自建服务除了要面对潜在的供应链风险(项目本身、作者提供的回调 API 等),** 其稳定性也完全受限于我们自己的服务器和运维能力,强如 DMIT,上个月也因为大楼的疑似火警,宕机了几个小时,我们购买的服务器,其 SLA 更是无法与专业的服务商相提并论。因此,我决定寻找一个由专业团队维护、提供原生 WebDAV 支持的云存储服务作为替代。

寻找替代方案:从自建到专业服务 #

我的选型标准很明确: 可靠的专业服务、良好的隐私策略、以及原生的 WebDAV 支持。

坚果云 是一个优秀的选择,可惜但在免费套餐下,每月 1GB 的上传和 3GB 的下载流量,对于需要频繁同步的 WebDAV 场景来说,限制较大。

随后,我注意到了 Koofr 。这是一家欧洲的服务商,以注重用户隐私而闻名。其免费套餐相当有诚意:

10 GB 存储空间

不限制单文件大小

支持 WebDAV

每日 50 GB 的公开分享带宽

可以挂载 OneDrive 和 Google Drive

这些特性几乎完美满足了我所有的需求,尤其是原生 WebDAV 支持,免去了自行部署和维护的麻烦。

新的问题:网络延迟与访问速度 #

然而,在将我的 WebDAV 客户端指向 Koofr 后,一个新的问题出现了。由于其服务器位于欧洲,使用本地网络直接访问的连接质量非常不稳定, WebDAV 的传输速度长期维持在几十 KB/s,这让文件同步几乎无法正常进行。

放弃如此契合的服务实在可惜,我需要一个更轻量、高效的解决方案。

解决方案:利用 Deno Playground 搭建轻量级中继 #

此时,我想起了之前编写的一个部署在 Deno 上的反向代理脚本,这个脚本原先是为了代理那些连通性比较差的 AI 的 API 用的,能代理这些 AI,也就能代理 Koofr!

Deno 的 Playground 运行在谷歌云上,虽然是谷歌云但是使用本地网络访问起来速度很不错,它的免费额度(每月 100 万次请求,100GB 出站流量)也非常充裕。

我的想法是,将这个脚本稍作修改,让他支持 Koofr 的 WebDAV 代理。所有本地到 Koofr 的请求,都先经过 Deno Deploy 的节点进行转发,从而绕开不佳的国际链路,实现加速效果。

核心代码如下 #

改造的核心是增加对 Koofr 域名的代理,并特殊处理 WebDAV 的 PROPFIND 方法。因为 WebDAV 客户端在列出目录时,响应的 XML 文件中包含了文件的 URL 路径。代理必须将这些路径从目标服务器的路径(如 /dav/Koofr/file.txt )重写为代理服务器的路径(如 /koofr/dav/Koofr/file.txt ),客户端才能正确识别。

import { serve , type ServeHandlerInfo } from " https://deno.land/ [email protected] /http/server.ts " ;

// 定义路径到目标URL的映射 // 键是代理服务器接收到的路径前缀,值是对应的目标服务器基础URL const pathMappings : Record < string , string > = { ' /anthropic ' : ' https://api.anthropic.com ' , ' /gemini ' : ' https://generativelanguage.googleapis.com ' , ' /openai ' : ' https://api.openai.com ' , // 目标是根路径 ' /openrouter ' : ' https://openrouter.ai/api ' , // 目标本身有路径 /api ' /xai ' : ' https://api.x.ai ' , ' /telegram ' : ' https://api.telegram.org ' , ' /discord ' : ' https://discord.com/api ' , // 目标本身有路径 /api ' /groq ' : ' https://api.groq.com/openai ' , // 目标本身有路径 /openai ' /cohere ' : ' https://api.cohere.ai ' , ' /huggingface ' : ' https://api-inference.huggingface.co ' , ' /together ' : ' https://api.together.xyz ' , ' /novita ' : ' https://api.novita.ai ' , ' /portkey ' : ' https://api.portkey.ai ' , ' /fireworks ' : ' https://api.fireworks.ai/inference ' , // 目标本身有路径 /inference ' /koofr ' : ' https://app.koofr.net ' , };

// 从环境变量 "PORT" 获取端口号,如果未设置则默认为 8000 const port = parseInt ( Deno . env . get ( " PORT " ) || " 8000 " );

console . log ( 代理服务器正在启动,监听端口: http://localhost: ${ port } );

// 启动HTTP服务器,为每个请求调用回调函数 serve ( async ( req : Request , _connInfo : ServeHandlerInfo ) => { const incomingUrl = new URL ( req . url ); // 解析入站请求的URL const incomingPathname = incomingUrl . pathname ; // 获取入站请求的路径部分

// 创建包含安全相关头部的 Headers 对象 const createSecureHeaders = ( contentType ? : string ): Headers => { const headers = new Headers (); if ( contentType ) { headers . set ( ' Content-Type ' , contentType ); } headers . set ( ' X-Content-Type-Options ' , ' nosniff ' ); headers . set ( ' X-Frame-Options ' , ' DENY ' ); headers . set ( ' Referrer-Policy ' , ' no-referrer ' ); return headers ; };

if ( incomingPathname === ' / ' || incomingPathname === ' /index.html ' ) { return new Response ( null , { status : 404 , headers : createSecureHeaders () }); }

if ( incomingPathname === ' /robots.txt ' ) { return new Response ( ' User-agent: * Disallow: / ' , { status : 200 , headers : createSecureHeaders ( ' text/plain ' ) }); }

let targetBaseUrlString : string | undefined ; let matchedPrefix : string | undefined ;

for ( const prefix in pathMappings ) { if ( incomingPathname . startsWith ( prefix )) { targetBaseUrlString = pathMappings [ prefix ]; matchedPrefix = prefix ; break ; } }

if ( ! targetBaseUrlString || ! matchedPrefix ) { console . warn ( [ ${ new Date (). toISOString () } ] 未找到路径映射: ${ incomingPathname } ); return new Response ( " 未找到: 此路径没有代理映射。 " , { status : 404 , headers : createSecureHeaders ( ' text/plain ' ) }); }

const parsedTargetBaseUrl = new URL ( targetBaseUrlString ); // suffixPath 是入站请求路径中,匹配掉代理前缀后的剩余部分。 // 例如:incomingPathname = "/openrouter/v1/chat", matchedPrefix = "/openrouter" -> suffixPath = "/v1/chat" // 例如:incomingPathname = "/openai/foo", matchedPrefix = "/openai" -> suffixPath = "/foo" // 例如:incomingPathname = "/openrouter", matchedPrefix = "/openrouter" -> suffixPath = "" const suffixPath = incomingPathname . substring ( matchedPrefix . length );

// --- 路径拼接逻辑 --- // 1. 规范化基础URL (baseForNewUrl) // 目标是确保如果 parsedTargetBaseUrl 本身有路径 (其 pathname 不是根路径"/"), // 那么 baseForNewUrl 的字符串表示应该以 "/" 结尾,以便 new URL() 正确地追加相对路径。 let baseForNewUrl = parsedTargetBaseUrl . href ; // 初始为映射中定义的URL的href字符串 // e.g., "https://api.openai.com/" or "https://openrouter.ai/api"

// 检查 parsedTargetBaseUrl.pathname: // - "https://api.openai.com" -> pathname is "/" // - "https://openrouter.ai/api" -> pathname is "/api" // - "https://openrouter.ai/api/" -> pathname is "/api/" if ( parsedTargetBaseUrl . pathname !== ' / ' && ! baseForNewUrl . endsWith ( ' / ' )) { // 如果目标URL的路径不是根目录,并且其href不以'/'结尾 (例如 "https://host.com/path") // 则在其末尾添加'/',使其变为 "https://host.com/path/" baseForNewUrl += ' / ' ; } // 现在 baseForNewUrl 对于有路径的基础URL,其路径部分会以'/'结尾 (如 "https://host.com/path/") // 对于根路径的基础URL,它可能是 "https://host.com/" 或 "https://host.com" (URL构造函数都能正确处理)

// 2. 规范化要追加的路径 (pathForNewUrl) // 目标是确保从 suffixPath 得到的路径是一个相对路径(不以 "/" 开头)。 let pathForNewUrl = suffixPath ; // - suffixPath = "/v1/chat" -> pathForNewUrl = "v1/chat" // - suffixPath = "v1/chat" -> pathForNewUrl = "v1/chat" (不变) // - suffixPath = "" -> pathForNewUrl = "" (不变) if ( pathForNewUrl . startsWith ( ' / ' )) { pathForNewUrl = pathForNewUrl . substring ( 1 ); }

// 3. 构建最终的目标URL // 使用规范化的 baseForNewUrl 和 pathForNewUrl。 // 例如: // - req: /openai/v1/chat -> target: https://api.openai.com, suffix: /v1/chat // base: "https://api.openai.com/" (或 "https://api.openai.com"), path: "v1/chat" // -> new URL("v1/chat", "https://api.openai.com/") -> https://api.openai.com/v1/chat // - req: /openrouter/v1/chat -> target: https://openrouter.ai/api, suffix: /v1/chat // base: "https://openrouter.ai/api/", path: "v1/chat" // -> new URL("v1/chat", "https://openrouter.ai/api/") -> https://openrouter.ai/api/v1/chat (正确!) // - req: /openrouter -> target: https://openrouter.ai/api, suffix: "" // base: "https://openrouter.ai/api/", path: "" // -> new URL("", "https://openrouter.ai/api/") -> https://openrouter.ai/api/ const finalTargetUrl = new URL ( pathForNewUrl + incomingUrl . search , baseForNewUrl ); // --- 路径拼接逻辑结束 ---

const headersToProxy = new Headers ( req . headers ); headersToProxy . set ( " Host " , finalTargetUrl . host ); // 使用最终目标URL的主机名

// 删除不应被代理的 "hop-by-hop" 头部 headersToProxy . delete ( " X-Forwarded-For " ); headersToProxy . delete ( " X-Real-IP " ); headersToProxy . delete ( " Forwarded " ); headersToProxy . delete ( " Via " );

const proxyReq = new Request ( finalTargetUrl . toString (), { method : req . method , headers : headersToProxy , body : req . body , redirect : " manual " , // 代理服务器不应自动处理重定向 });

try { const proxyRes = await fetch ( proxyReq );

// --- WebDAV 路径重写逻辑 --- // 检查是否为需要重写响应体的特定请求。 // 针对 Koofr WebDAV 的场景:当请求方法为 PROPFIND (WebDAV用于列出目录内容的方法) // 且请求路径前缀为 /koofr 时,我们需要修改响应内容。 if ( matchedPrefix === ' /koofr ' && req . method === ' PROPFIND ' ) { const contentType = proxyRes . headers . get ( ' Content-Type ' ) || '' ; // 确保我们只修改XML响应,这是WebDAV列表的格式。 if ( proxyRes . ok && ( contentType . includes ( ' application/xml ' ) || contentType . includes ( ' text/xml ' ))) { const originalBodyText = await proxyRes . text ();

// suffixPath 是目标服务器上的路径, 例如 "/dav/Koofr/webdav" // incomingPathname 是客户端请求代理服务器的完整路径, 例如 "/koofr/dav/Koofr/webdav" // 目标服务器返回的XML内容中包含了大量的 suffixPath。 // 我们需要将它们全部替换为 incomingPathname,以便客户端能构建正确的URL。 // 例如,将 XML 中的 href="/dav/Koofr/webdav/file.zip" 替换为 href="/koofr/dav/Koofr/webdav/file.zip"。

// 使用 String.prototype.replaceAll() 进行全局替换。 // 增加一个安全检查,避免 suffixPath 为 "/" 时替换掉所有根路径。 if ( suffixPath && suffixPath . length > 1 ) { const rewrittenBody = originalBodyText . replaceAll ( suffixPath , incomingPathname );

const responseHeaders = new Headers ( proxyRes . headers ); // 因为我们修改了响应体,原有的 'Content-Length' 头不再准确, // 需要删除它,以便Deno/HTTP服务器自动重新计算。 responseHeaders . delete ( ' Content-Length ' );

// 为代理响应添加/确保我们自己的安全头部 responseHeaders . set ( ' X-Content-Type-Options ' , ' nosniff ' ); responseHeaders . set ( ' X-Frame-Options ' , ' DENY ' ); responseHeaders . set ( ' Referrer-Policy ' , ' no-referrer ' );

// 返回修改后的响应 return new Response ( rewrittenBody , { status : proxyRes . status , statusText : proxyRes . statusText , headers : responseHeaders , }); } } } // --- WebDAV 路径重写逻辑结束 ---

// 对于所有其他请求,或者不满足上述重写条件的请求,执行原始的透传逻辑。 const responseHeaders = new Headers ( proxyRes . headers );

// 从目标服务器的响应中删除 hop-by-hop 头部 responseHeaders . delete ( " Transfer-Encoding " ); responseHeaders . delete ( " Connection " ); responseHeaders . delete ( " Keep-Alive " ); responseHeaders . delete ( " Proxy-Authenticate " ); responseHeaders . delete ( " Proxy-Authorization " ); responseHeaders . delete ( " TE " ); responseHeaders . delete ( " Trailers " ); responseHeaders . delete ( " Upgrade " );

// 为代理响应添加/确保我们自己的安全头部 responseHeaders . set ( ' X-Content-Type-Options ' , ' nosniff ' ); responseHeaders . set ( ' X-Frame-Options ' , ' DENY ' ); responseHeaders . set ( ' Referrer-Policy ' , ' no-referrer ' );

return new Response ( proxyRes . body , { status : proxyRes . status , statusText : proxyRes . statusText , headers : responseHeaders , });

} catch ( error ) { console . error ( [ ${ new Date (). toISOString () } ] 请求目标URL时出错 ${ finalTargetUrl . toString () } : , error ); return new Response ( " 网关错误: 连接上游服务器时出错。 " , { status : 502 , headers : createSecureHeaders ( ' text/plain ' ) }); }

}, { port });

部署步骤 #

部署过程非常简单,且完全免费:

访问 https://dash.deno.com 并使用 GitHub 账号登录。

点击 New Playground ,将完整的代理代码粘贴进去。

点击 Save & Deploy 。部署完成后,Deno 会提供一个格式为 https://[project-name].deno.dev 的公开域名。

如何使用 #

假设你获得的域名是 https://my-proxy.deno.dev

现在,在你使用的任何 WebDAV 客户端中,将服务器地址从:

https://app.koofr.net 替换为 https://my-proxy.deno.dev/koofr

例如,完整的 WebDAV 访问 URL 将从 https://app.koofr.net/dav/Koofr 变为 https://my-proxy.deno.dev/koofr/dav/Koofr

配置完成后,Koofr 的 WebDAV 访问速度得到了质的提升,文件同步恢复了应有的流畅。

总结 #

最终,这个 “ Koofr + Deno ” 的组合方案,让我以零成本的方式,获得了一个兼具 高可靠性、隐私保护和高速访问 的个人 WebDAV 服务。

Deno 肯定会积极维护自己的 Playground,谷歌云的 SLA 也很不错,即使这两个挂了,我们还是可以使用 Koofr 自己本身的链接进行不那么高速的同步,只有 Koofr 自己挂了才可能无法同步,健壮性应该是比自建 Alist 作为 webdav 方案要强很多

P.S. #

这个代码其实本身是用来代理各种 AI 的 API 用的,也就说,你可以通过这个中继,来更丝滑的使用 AI 的 API,而不用担心网络 / 地区问题。

代理地址中的 [project-name] 是一个占位符,您需要将其替换为您的实际 Deno Deploy 项目名称。

代理地址 等同于 https://[project-name].deno.dev/anthropic https://api.anthropic.com https://[project-name].deno.dev/gemini https://generativelanguage.googleapis.com https://[project-name].deno.dev/openai https://api.openai.com https://[project-name].deno.dev/openrouter https://openrouter.ai/api https://[project-name].deno.dev/xai https://api.x.ai https://[project-name].deno.dev/telegram https://api.telegram.org https://[project-name].deno.dev/discord https://discord.com/api https://[project-name].deno.dev/groq https://api.groq.com/openai https://[project-name].deno.dev/cohere https://api.cohere.ai https://[project-name].deno.dev/huggingface https://api-inference.huggingface.co https://[project-name].deno.dev/together https://api.together.xyz https://[project-name].deno.dev/novita https://api.novita.ai https://[project-name].deno.dev/portkey https://api.portkey.ai https://[project-name].deno.dev/fireworks https://api.fireworks.ai/inference https://[project-name].deno.dev/koofr https://app.koofr.net

你问为什么选择 Deno Playground 而不是 Cloudflare Worker?

因为 Worker 反代会传透请求的请求头,如果你没有主动过滤,那你的真实 IP、浏览器的语言、浏览器的 UA、浏览器的版本和型号都会被传给被反代的网站。

即使你使用自构造的或者主动过滤了相关的请求头,尽管你的真实 IP 不会泄漏,但是你的 Workers 域名和真实 IP 所属国家仍然会被传透过去。

不论你怎样修改代码,你会发现 cf-ipcountry 和 cf-worker 是无论如何都去不掉的,这就是反代 github 等网站被 Netcraft 轻易发现并被举报的根本原因。

但是, Deno 的 Acceptable use policy 中明确表示了,不应该使用他作为正向代理使用 ,本方法的核心就是那一段 Deno 代码,去薅个 supabse /fastly 然后用它的 serverless function ,似乎是一个阉割版本 Deno,也是一种选择

放在自己的服务器上也可以,虽然需要自己维护服务器,但这段代码至少不会涉及到商业利益了(笑)

寻找替代方案:从自建到专业服务 " class="text-zinc-400 font-medium truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 1. 寻找替代方案:从自建到专业服务 新的问题:网络延迟与访问速度 " class="text-zinc-400 font-medium truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 2. 新的问题:网络延迟与访问速度 解决方案:利用 Deno Playground搭建轻量级中继 " class="text-zinc-400 font-medium truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 3. 解决方案:利用 Deno Playground搭建轻量级中继 核心代码如下 " class="text-zinc-400 font-medium truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 4. 核心代码如下 部署步骤 " class="text-accent font-bold truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 5. 部署步骤 如何使用 " class="text-zinc-400 font-medium truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 6. 如何使用 总结 " class="text-zinc-400 font-medium truncate inline-block max-w-full align-bottom hover:text-accent cursor-pointer"> 7. 总结 8. P.S.

0

0

0

0

0

0 评论

在区块链上写下你的评论

免登录评论 连接

此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。

区块链标识 #56109-33 (已被 10704539 个区块确认)

所有者 0x4ab740175c609fd9e9316d69e3e74cef4ba22fef

交易哈希 创作 0x63ba0daa...d458010785 最后更新 0x706cde6b...bb702aa959

IPFS 地址 ipfs://QmPNAngQ2a13srSjSiqAPVrapXdedsmmyGUM7yBVRaeGL8

© Lainbo · 由

提供支持

相关文章