Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Free serverless backend with a limit of 100,000 invocation requests per day.
- Create folders
- Search files
- Image/video/PDF thumbnails
- File sharing with expirable links
- WebDAV endpoint
- Drag and drop upload

Expand All @@ -29,9 +30,12 @@ Steps:
- Select `Docusaurus` framework preset
- Set `WEBDAV_USERNAME` and `WEBDAV_PASSWORD`
- (Optional) Set `WEBDAV_PUBLIC_READ` to `1` to enable public read
- (Optional) Set `SHARE_ENABLED` to `true` to enable file sharing feature
- (Optional) Set `SHARE_DEFAULT_EXPIRE_SECONDS` to customize share link expiration time (default: 3600 seconds / 1 hour)
2. After initial deployment, bind your R2 bucket to `BUCKET` variable
3. Retry deployment in `Deployments` page to apply the changes
4. (Optional) Add a custom domain
3. (Optional) If you enabled file sharing, create a KV namespace and bind it to `SHARE_KV` variable
4. Retry deployment in `Deployments` page to apply the changes
5. (Optional) Add a custom domain

You can also deploy this project using Wrangler CLI:

Expand All @@ -49,6 +53,19 @@ Fill the endpoint URL as `https://<your-domain.com>/webdav` and use the username
However, the standard WebDAV protocol does not support large file (≥128MB) uploads due to the limitation of Cloudflare Workers.
You must upload large files through the web interface which supports chunked uploads.

### File Sharing

If you have enabled the file sharing feature by setting `SHARE_ENABLED` to `true`, you can create temporary share links for your files:

- Share links are generated through the web interface
- Each share link has an expiration time (default: 1 hour, configurable via `SHARE_DEFAULT_EXPIRE_SECONDS`)
- Share links are accessible at `https://<your-domain.com>/s/<token>`
- Only one active share link can exist per file at a time
- Creating a new share link for a file will invalidate any existing share link for that file
- Share links automatically expire after the configured time period

**Note**: The file sharing feature requires a KV namespace binding (`SHARE_KV`). Make sure to create and bind a KV namespace in your Cloudflare Pages settings.

## Acknowledgments

WebDAV related code is based on [r2-webdav](
Expand Down
111 changes: 111 additions & 0 deletions functions/api/share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { requireAuthSimple } from "../../utils/auth";

interface ShareRequest {
filePath: string;
}

interface ShareResponse {
shareUrl: string;
expireTime: string;
expireSeconds: number;
fileName: string;
}

function generateShareToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
}

export async function onRequestPost(context: any): Promise<Response> {
const { request, env } = context;

const authError = requireAuthSimple(
request,
env.WEBDAV_USERNAME,
env.WEBDAV_PASSWORD
);
if (authError) return authError;

if (env.SHARE_ENABLED !== "true") {
return new Response("Share functionality is disabled", { status: 403 });
}

try {
const body: ShareRequest = await request.json();
if (!body?.filePath) {
return new Response("filePath is required", { status: 400 });
}

const expireSeconds = parseInt(env.SHARE_DEFAULT_EXPIRE_SECONDS || "3600", 10);

const bucket = env.BUCKET;
const kv = env.SHARE_KV;
if (!bucket) return new Response("Bucket not found", { status: 500 });
if (!kv) return new Response("KV binding SHARE_KV not found", { status: 500 });

const meta = await bucket.head(body.filePath);
if (!meta) return new Response("File not found", { status: 404 });

const pathKey = `path:${body.filePath}`;
const existingTokenData = await kv.get(pathKey);

let token: string;
let isNewShare = true;

if (existingTokenData) {
const existingToken = existingTokenData;
const existingData = await kv.get(existingToken);

if (existingData) {
await kv.delete(existingToken);
await kv.delete(pathKey);
}
}

token = generateShareToken();

await kv.put(token, JSON.stringify({ filePath: body.filePath }), {
expirationTtl: expireSeconds,
});

await kv.put(pathKey, token, {
expirationTtl: expireSeconds,
});

const origin = new URL(request.url).origin;
const shareUrl = `${origin}/s/${token}`;
const expireTime = new Date(Date.now() + expireSeconds * 1000).toISOString();
const fileName = body.filePath.split("/").pop() || "";

const response: ShareResponse = {
shareUrl,
expireTime,
expireSeconds,
fileName,
};

return new Response(JSON.stringify(response), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Cache-Control": "no-store",
},
});
} catch (error: any) {
return new Response(error?.message || "Internal server error", { status: 500 });
}
}

export async function onRequestOptions(): Promise<Response> {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
});
}
25 changes: 25 additions & 0 deletions functions/s/[token].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const onRequestGet: PagesFunction = async ({ params, env }) => {
const token = params?.token as string | undefined;
if (!token) return new Response("Bad Request", { status: 400 });

const kv = env.SHARE_KV;
const bucket = env.BUCKET;
if (!kv) return new Response("KV binding SHARE_KV not found", { status: 500 });
if (!bucket) return new Response("Bucket not found", { status: 500 });

const rec = await kv.get(token, "json") as { filePath: string } | null;
if (!rec?.filePath) return new Response("Link expired or invalid", { status: 410 });

const obj = await bucket.get(rec.filePath);
if (!obj) return new Response("File not found", { status: 404 });

const filename = rec.filePath.split("/").pop() || "file";
const headers: Record<string, string> = {
"Content-Type": obj.httpMetadata?.contentType || "application/octet-stream",
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
"Cache-Control": "no-store",
};
if (obj.size) headers["Content-Length"] = String(obj.size);

return new Response(obj.body, { status: 200, headers });
};
51 changes: 24 additions & 27 deletions functions/webdav/[[path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { handleRequestPropfind } from "./propfind";
import { handleRequestPut } from "./put";
import { RequestHandlerParams } from "./utils";
import { handleRequestPost } from "./post";
import { requireAuth } from "../../utils/auth";

async function handleRequestOptions() {
return new Response(null, {
Expand Down Expand Up @@ -38,41 +39,37 @@ const HANDLERS: Record<
DELETE: handleRequestDelete,
};

export const onRequest: PagesFunction<{
WEBDAV_USERNAME: string;
WEBDAV_PASSWORD: string;
WEBDAV_PUBLIC_READ?: string;
}> = async function (context) {
export const onRequest = async function (context: {
request: Request;
env: {
WEBDAV_USERNAME: string;
WEBDAV_PASSWORD: string;
WEBDAV_PUBLIC_READ?: string;
[key: string]: any;
};
params: any;
}) {
const env = context.env;
const request: Request = context.request;
if (request.method === "OPTIONS") return handleRequestOptions();

const skipAuth =
env.WEBDAV_PUBLIC_READ === "1" &&
["GET", "HEAD", "PROPFIND"].includes(request.method);

if (!skipAuth) {
if (!env.WEBDAV_USERNAME || !env.WEBDAV_PASSWORD)
return new Response("WebDAV protocol is not enabled", { status: 403 });

const auth = request.headers.get("Authorization");
if (!auth) {
return new Response("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": `Basic realm="WebDAV"` },
});
}
const expectedAuth = `Basic ${btoa(
`${env.WEBDAV_USERNAME}:${env.WEBDAV_PASSWORD}`
)}`;
if (auth !== expectedAuth)
return new Response("Unauthorized", { status: 401 });
}
const authError = requireAuth(request, {
username: env.WEBDAV_USERNAME,
password: env.WEBDAV_PASSWORD,
publicRead: env.WEBDAV_PUBLIC_READ === "1",
});
if (authError) return authError;

const [bucket, path] = parseBucketPath(context);
if (!bucket) return notFound();

const method: string = (context.request as Request).method;
const handler = HANDLERS[method] ?? handleMethodNotAllowed;
return handler({ bucket, path, request: context.request });

const params: RequestHandlerParams = { bucket, path, request: context.request };
if (method === 'DELETE' || method === 'MOVE') {
params.env = context.env;
}

return handler(params);
};
28 changes: 28 additions & 0 deletions functions/webdav/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,45 @@ import { listAll, RequestHandlerParams } from "./utils";
export async function handleRequestDelete({
bucket,
path,
env,
}: RequestHandlerParams) {
const kv = env?.SHARE_KV;

if (path !== "") {
const obj = await bucket.head(path);
if (obj === null) return notFound();

const pathKey = `path:${path}`;
const token = await kv?.get(pathKey);

if (token) {
const tokenData = await kv?.get(token, "json") as { filePath: string } | null;

if (tokenData && tokenData.filePath === path) {
await kv?.delete(token);
await kv?.delete(pathKey);
}
}

await bucket.delete(path);

if (obj.httpMetadata?.contentType !== "application/x-directory")
return new Response(null, { status: 204 });
}

const children = listAll(bucket, path === "" ? undefined : `${path}/`);
for await (const child of children) {
const pathKey = `path:${child.key}`;
const token = await kv?.get(pathKey);

if (token) {
const tokenData = await kv?.get(token, "json") as { filePath: string } | null;

if (tokenData && tokenData.filePath === child.key) {
await kv?.delete(token);
await kv?.delete(pathKey);
}
}
await bucket.delete(child.key);
}

Expand Down
Loading