Lainbo

Lainbo's Blog

If you’re nothing without the suit, then you shouldn't have it.
github
email
follow

After giving up on building Alist myself, how I use Koofr and Deno to create a stable and high-speed WebDAV.

Recently, the well-known open-source project Alist has experienced a series of controversies, triggering widespread concerns in the community about supply chain security. For me personally, this incident is not only a security warning but also an opportunity to reassess my long-standing strategy of relying on self-built services.

I use Alist, primarily valuing its powerful WebDAV service and multi-user permission features, rather than its ability to link to a bunch of cloud storage services. It has allowed me to easily set up a personal data synchronization center on my own server. However, this incident made me realize that self-built services not only face potential supply chain risks (such as the project itself, callback APIs provided by the author, etc.), but their stability is also entirely dependent on our own server and operational capabilities. Even a strong service like DMIT experienced downtime for several hours last month due to a suspected fire alarm in the building. The SLA of the servers we purchased cannot compare to that of professional service providers. Therefore, I decided to look for a cloud storage service maintained by a professional team that provides native WebDAV support as an alternative.

Looking for Alternatives: From Self-Built to Professional Services#

My selection criteria are clear: reliable professional services, good privacy policies, and native WebDAV support.

  • Nutshell Cloud is an excellent choice, but unfortunately, under the free plan, the monthly upload limit of 1GB and download limit of 3GB is quite restrictive for WebDAV scenarios that require frequent synchronization.
  • Subsequently, I noticed Koofr. This is a European service provider known for its focus on user privacy. Its free plan is quite generous:
    • 10 GB of storage space
    • No limit on individual file size
    • Supports WebDAV
    • 50 GB of public sharing bandwidth per day
    • Can mount OneDrive and Google Drive

These features almost perfectly meet all my needs, especially the native WebDAV support, which eliminates the hassle of self-deployment and maintenance.

New Issue: Network Latency and Access Speed#

However, after pointing my WebDAV client to Koofr, a new problem arose. Since its servers are located in Europe, the connection quality when accessing directly via local networks is very unstable, with WebDAV transfer speeds consistently hovering around tens of KB/s, making file synchronization nearly impossible.

It is indeed a pity to give up such a fitting service, and I need a lighter, more efficient solution.

Solution: Using Deno Playground to Build a Lightweight Relay#

At this point, I remembered a reverse proxy script I had previously written and deployed on Deno. This script was originally designed to proxy APIs of AI services with poor connectivity, and if it can proxy those AIs, it can also proxy Koofr!

Deno's Playground runs on Google Cloud, and although it's Google Cloud, the speed when accessed via local networks is quite good. Its free quota (1 million requests per month, 100GB outbound traffic) is also very ample.

My idea is to modify this script slightly to support Koofr's WebDAV proxy. All requests from local to Koofr will first be forwarded through Deno Deploy's nodes to bypass the poor international link, achieving acceleration.

Core Code Below#

The core modification is to add proxy support for the Koofr domain and to specially handle the WebDAV PROPFIND method. When the WebDAV client lists directories, the XML response includes the URL paths of the files. The proxy must rewrite these paths from the target server's paths (like /dav/Koofr/file.txt) to the proxy server's paths (like /koofr/dav/Koofr/file.txt) so that the client can recognize them correctly.

import { serve, type ServeHandlerInfo } from "https://deno.land/std@0.224.0/http/server.ts";

// Define the mapping of paths to target URLs
// The key is the prefix of the path received by the proxy server, and the value is the corresponding target server's base URL
const pathMappings: Record<string, string> = {
  '/anthropic': 'https://api.anthropic.com',
  '/gemini': 'https://generativelanguage.googleapis.com',
  '/openai': 'https://api.openai.com', // Target is the root path
  '/openrouter': 'https://openrouter.ai/api', // Target itself has the path /api
  '/xai': 'https://api.x.ai',
  '/telegram': 'https://api.telegram.org',
  '/discord': 'https://discord.com/api',    // Target itself has the path /api
  '/groq': 'https://api.groq.com/openai',   // Target itself has the path /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', // Target itself has the path /inference
  '/koofr': 'https://app.koofr.net',
};

// Get the port number from the environment variable "PORT", defaulting to 8000 if not set
const port = parseInt(Deno.env.get("PORT") || "8000");

console.log(`Proxy server is starting, listening on port: http://localhost:${port}`);

// Start the HTTP server and call the callback function for each request
serve(async (req: Request, _connInfo: ServeHandlerInfo) => {
  const incomingUrl = new URL(req.url); // Parse the incoming request's URL
  const incomingPathname = incomingUrl.pathname; // Get the path part of the incoming request

  // Create a Headers object with security-related 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: *\nDisallow: /', {
      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()}] Path mapping not found: ${incomingPathname}`);
    return new Response("Not Found: This path has no proxy mapping.", {
      status: 404,
      headers: createSecureHeaders('text/plain')
    });
  }

  const parsedTargetBaseUrl = new URL(targetBaseUrlString);
  const suffixPath = incomingPathname.substring(matchedPrefix.length);

  // --- Path concatenation logic ---
  let baseForNewUrl = parsedTargetBaseUrl.href;

  if (parsedTargetBaseUrl.pathname !== '/' && !baseForNewUrl.endsWith('/')) {
    baseForNewUrl += '/';
  }

  let pathForNewUrl = suffixPath;
  if (pathForNewUrl.startsWith('/')) {
    pathForNewUrl = pathForNewUrl.substring(1);
  }

  const finalTargetUrl = new URL(pathForNewUrl + incomingUrl.search, baseForNewUrl);

  const headersToProxy = new Headers(req.headers);
  headersToProxy.set("Host", finalTargetUrl.host);

  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 path rewriting logic ---
    if (matchedPrefix === '/koofr' && req.method === 'PROPFIND') {
      const contentType = proxyRes.headers.get('Content-Type') || '';
      if (proxyRes.ok && (contentType.includes('application/xml') || contentType.includes('text/xml'))) {
        const originalBodyText = await proxyRes.text();

        if (suffixPath && suffixPath.length > 1) {
          const rewrittenBody = originalBodyText.replaceAll(suffixPath, incomingPathname);
          
          const responseHeaders = new Headers(proxyRes.headers);
          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,
          });
        }
      }
    }

    const responseHeaders = new Headers(proxyRes.headers);
    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()}] Error requesting target URL ${finalTargetUrl.toString()}:`, error);
    return new Response("Gateway Error: Error connecting to upstream server.", {
      status: 502,
      headers: createSecureHeaders('text/plain')
    });
  }

}, { port });

Deployment Steps#

The deployment process is very simple and completely free:

  1. Visit https://dash.deno.com and log in with your GitHub account.
  2. Click New Playground and paste the complete proxy code into it.
  3. Click Save & Deploy. After deployment, Deno will provide a public domain in the format of https://[project-name].deno.dev.

How to Use#

Assuming the domain you obtained is https://my-proxy.deno.dev.

Now, in any WebDAV client you use, replace the server address from:
https://app.koofr.net to https://my-proxy.deno.dev/koofr

For example, the complete WebDAV access URL will change from https://app.koofr.net/dav/Koofr to https://my-proxy.deno.dev/koofr/dav/Koofr.

After configuration, the WebDAV access speed to Koofr has significantly improved, and file synchronization has returned to its expected smoothness.

Summary#

Ultimately, this “Koofr + Deno” combination solution allows me to obtain a personal WebDAV service that is highly reliable, privacy-protecting, and fast-accessing at zero cost. Deno will certainly actively maintain its Playground, and Google Cloud's SLA is also quite good. Even if both go down, we can still use Koofr's own links for less speedy synchronization. The robustness should be much stronger than using self-built Alist as a WebDAV solution.

P.S.#

This code is actually used to proxy various AI APIs, meaning you can use this relay to access AI APIs more smoothly without worrying about network or regional issues.

The [project-name] in the proxy address is a placeholder; you need to replace it with your actual Deno Deploy project name.

Proxy AddressEquivalent to
https://[project-name].deno.dev/anthropichttps://api.anthropic.com
https://[project-name].deno.dev/geminihttps://generativelanguage.googleapis.com
https://[project-name].deno.dev/openaihttps://api.openai.com
https://[project-name].deno.dev/openrouterhttps://openrouter.ai/api
https://[project-name].deno.dev/xaihttps://api.x.ai
https://[project-name].deno.dev/telegramhttps://api.telegram.org
https://[project-name].deno.dev/discordhttps://discord.com/api
https://[project-name].deno.dev/groqhttps://api.groq.com/openai
https://[project-name].deno.dev/coherehttps://api.cohere.ai
https://[project-name].deno.dev/huggingfacehttps://api-inference.huggingface.co
https://[project-name].deno.dev/togetherhttps://api.together.xyz
https://[project-name].deno.dev/novitahttps://api.novita.ai
https://[project-name].deno.dev/portkeyhttps://api.portkey.ai
https://[project-name].deno.dev/fireworkshttps://api.fireworks.ai/inference
https://[project-name].deno.dev/koofrhttps://app.koofr.net

You may wonder why I chose Deno Playground instead of Cloudflare Worker?

Because Workers will pass through the request headers, if you do not actively filter them, your real IP, browser language, browser UA, browser version, and model will all be sent to the proxied website. Even if you use self-constructed or actively filtered request headers, although your real IP will not be leaked, your Workers domain and the country of your real IP will still be passed through. No matter how you modify the code, you will find that cf-ipcountry and cf-worker cannot be removed, which is the fundamental reason why reverse proxies for sites like GitHub are easily discovered and reported by Netcraft.

However, Deno's Acceptable Use Policy clearly states that it should not be used as a forward proxy. The core of this method is that segment of Deno code, to grab a supabase / fastly and then use its serverless function, which seems to be a stripped-down version of Deno, is also an option.
image

You can also run it on your own server, although it requires maintaining the server, this code will at least not involve commercial interests (laughs).

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.