UNPKG

r2-explorer

Version:

A Google Drive Interface for your Cloudflare R2 Buckets

753 lines (731 loc) 21.9 kB
// src/index.ts import { cloudflareAccess } from "@hono/cloudflare-access"; import { extendZodWithOpenApi, fromHono } from "chanfana"; import { Hono } from "hono"; import { basicAuth } from "hono/basic-auth"; import { cors } from "hono/cors"; import { z as z13 } from "zod"; // src/foundation/middlewares/readonly.ts async function readOnlyMiddleware(c, next) { const config = c.get("config"); if (config.readonly === true && !["GET", "HEAD"].includes(c.req.method)) { return Response.json( { success: false, errors: [ { code: 10005, message: "This instance is in ReadOnly Mode, no changes are allowed!" } ] }, { status: 401 } ); } await next(); } // package.json var version = "1.1.2"; // src/foundation/settings.ts var settings = { version }; // src/modules/buckets/createFolder.ts import { OpenAPIRoute } from "chanfana"; import { z } from "zod"; var CreateFolder = class extends OpenAPIRoute { schema = { operationId: "post-bucket-create-folder", tags: ["Buckets"], summary: "Create folder", request: { params: z.object({ bucket: z.string() }), body: { content: { "application/json": { schema: z.object({ key: z.string().describe("base64 encoded file key") }) } } } } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const key = decodeURIComponent(escape(atob(data.body.key))); return await bucket.put(key, "R2 Explorer Folder"); } }; // src/modules/buckets/deleteObject.ts import { OpenAPIRoute as OpenAPIRoute2 } from "chanfana"; import { z as z2 } from "zod"; var DeleteObject = class extends OpenAPIRoute2 { schema = { operationId: "post-bucket-delete-object", tags: ["Buckets"], summary: "Delete object", request: { params: z2.object({ bucket: z2.string() }), body: { content: { "application/json": { schema: z2.object({ key: z2.string().describe("base64 encoded file key") }) } } } } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const key = decodeURIComponent(escape(atob(data.body.key))); await bucket.delete(key); return { success: true }; } }; // src/modules/buckets/getObject.ts import { OpenAPIRoute as OpenAPIRoute3 } from "chanfana"; import { z as z3 } from "zod"; var GetObject = class extends OpenAPIRoute3 { schema = { operationId: "get-bucket-object", tags: ["Buckets"], summary: "Get Object", request: { params: z3.object({ bucket: z3.string(), key: z3.string().describe("base64 encoded file key") }) }, responses: { "200": { description: "File binary", schema: z3.string().openapi({ format: "binary" }) } } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; let filePath; try { filePath = decodeURIComponent(escape(atob(data.params.key))); } catch (e) { filePath = decodeURIComponent( escape(atob(decodeURIComponent(data.params.key))) ); } const object = await bucket.get(filePath); if (object === null) { return Response.json({ msg: "Object Not Found" }, { status: 404 }); } const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("etag", object.httpEtag); headers.set( "Content-Disposition", `attachment; filename="${filePath.split("/").pop()}"` ); return new Response(object.body, { headers }); } }; // src/modules/buckets/headObject.ts import { OpenAPIRoute as OpenAPIRoute4 } from "chanfana"; import { z as z4 } from "zod"; var HeadObject = class extends OpenAPIRoute4 { schema = { operationId: "Head-bucket-object", tags: ["Buckets"], summary: "Get Object", request: { params: z4.object({ bucket: z4.string(), key: z4.string().describe("base64 encoded file key") }) } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; let filePath; try { filePath = decodeURIComponent(escape(atob(data.params.key))); } catch (e) { filePath = decodeURIComponent( escape(atob(decodeURIComponent(data.params.key))) ); } const object = await bucket.head(filePath); if (object === null) { return Response.json({ msg: "Object Not Found" }, { status: 404 }); } return object; } }; // src/modules/buckets/listObjects.ts import { OpenAPIRoute as OpenAPIRoute5 } from "chanfana"; import { z as z5 } from "zod"; var ListObjects = class extends OpenAPIRoute5 { schema = { operationId: "get-bucket-list-objects", tags: ["Buckets"], summary: "List objects", request: { params: z5.object({ bucket: z5.string() }), query: z5.object({ limit: z5.number().optional(), prefix: z5.string().nullable().optional().describe("base64 encoded prefix"), cursor: z5.string().nullable().optional(), delimiter: z5.string().nullable().optional(), startAfter: z5.string().nullable().optional(), include: z5.enum(["httpMetadata", "customMetadata"]).array().optional() }) } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; c.header("Access-Control-Allow-Credentials", "asads"); return await bucket.list({ limit: data.query.limit, prefix: data.query.prefix ? decodeURIComponent(escape(atob(data.query.prefix))) : void 0, cursor: data.query.cursor, startAfter: data.query.startAfter, delimiter: data.query.delimiter ? data.query.delimiter : "", // @ts-ignore include: data.query.include }); } }; // src/modules/buckets/moveObject.ts import { OpenAPIRoute as OpenAPIRoute6 } from "chanfana"; import { z as z6 } from "zod"; var MoveObject = class extends OpenAPIRoute6 { schema = { operationId: "post-bucket-move-object", tags: ["Buckets"], summary: "Move object", request: { params: z6.object({ bucket: z6.string() }), body: { content: { "application/json": { schema: z6.object({ oldKey: z6.string().describe("base64 encoded file key"), newKey: z6.string().describe("base64 encoded file key") }) } } } } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const oldKey = decodeURIComponent(escape(atob(data.body.oldKey))); const newKey = decodeURIComponent(escape(atob(data.body.newKey))); const object = await bucket.get(oldKey); const resp = await bucket.put(newKey, object.body, { customMetadata: object.customMetadata, httpMetadata: object.httpMetadata }); await bucket.delete(oldKey); return resp; } }; // src/modules/buckets/multipart/completeUpload.ts import { OpenAPIRoute as OpenAPIRoute7 } from "chanfana"; import { z as z7 } from "zod"; var CompleteUpload = class extends OpenAPIRoute7 { schema = { operationId: "post-multipart-complete-upload", tags: ["Multipart"], summary: "Complete upload", request: { params: z7.object({ bucket: z7.string() }), body: { content: { "application/json": { schema: z7.object({ uploadId: z7.string(), parts: z7.object({ etag: z7.string(), partNumber: z7.number().int() }).array(), key: z7.string().describe("base64 encoded file key") }) } } } } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const uploadId = data.body.uploadId; const key = decodeURIComponent(escape(atob(data.body.key))); const parts = data.body.parts; const multipartUpload = await bucket.resumeMultipartUpload(key, uploadId); try { const resp = await multipartUpload.complete(parts); return { success: true, str: resp }; } catch (error) { return Response.json({ msg: error.message }, { status: 400 }); } } }; // src/modules/buckets/multipart/createUpload.ts import { OpenAPIRoute as OpenAPIRoute8 } from "chanfana"; import { z as z8 } from "zod"; var CreateUpload = class extends OpenAPIRoute8 { schema = { operationId: "post-multipart-create-upload", tags: ["Multipart"], summary: "Create upload", request: { params: z8.object({ bucket: z8.string() }), query: z8.object({ key: z8.string().describe("base64 encoded file key"), customMetadata: z8.string().nullable().optional().describe("base64 encoded json string"), httpMetadata: z8.string().nullable().optional().describe("base64 encoded json string") }) } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const key = decodeURIComponent(escape(atob(data.query.key))); let customMetadata = void 0; if (data.query.customMetadata) { customMetadata = JSON.parse( decodeURIComponent(escape(atob(data.query.customMetadata))) ); } let httpMetadata = void 0; if (data.query.httpMetadata) { httpMetadata = JSON.parse( decodeURIComponent(escape(atob(data.query.httpMetadata))) ); } return await bucket.createMultipartUpload(key, { customMetadata, httpMetadata }); } }; // src/modules/buckets/multipart/partUpload.ts import { OpenAPIRoute as OpenAPIRoute9 } from "chanfana"; import { z as z9 } from "zod"; var PartUpload = class extends OpenAPIRoute9 { schema = { operationId: "post-multipart-part-upload", tags: ["Multipart"], summary: "Part upload", request: { body: { content: { "application/octet-stream": { schema: z9.object({}).openapi({ type: "string", format: "binary" }) } } }, params: z9.object({ bucket: z9.string() }), query: z9.object({ key: z9.string().describe("base64 encoded file key"), uploadId: z9.string(), partNumber: z9.number().int() }) } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const key = decodeURIComponent(escape(atob(data.query.key))); const multipartUpload = bucket.resumeMultipartUpload( key, data.query.uploadId ); try { return await multipartUpload.uploadPart( data.query.partNumber, c.req.raw.body ); } catch (error) { return new Response(error.message, { status: 400 }); } } }; // src/modules/buckets/putMetadata.ts import { OpenAPIRoute as OpenAPIRoute10 } from "chanfana"; import { z as z10 } from "zod"; var PutMetadata = class extends OpenAPIRoute10 { schema = { operationId: "post-bucket-put-object-metadata", tags: ["Buckets"], summary: "Update object metadata", request: { params: z10.object({ bucket: z10.string(), key: z10.string().describe("base64 encoded file key") }), body: { content: { "application/json": { schema: z10.object({ customMetadata: z10.record(z10.string(), z10.any()), httpMetadata: z10.record(z10.string(), z10.any()) }).openapi("Object metadata") } } } } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; let filePath; try { filePath = decodeURIComponent(escape(atob(data.params.key))); } catch (e) { filePath = decodeURIComponent( escape(atob(decodeURIComponent(data.params.key))) ); } const object = await bucket.get(filePath); return await bucket.put(filePath, object.body, { customMetadata: data.body.customMetadata, httpMetadata: data.body.httpMetadata }); } }; // src/modules/buckets/putObject.ts import { OpenAPIRoute as OpenAPIRoute11 } from "chanfana"; import { z as z11 } from "zod"; var PutObject = class extends OpenAPIRoute11 { schema = { operationId: "post-bucket-upload-object", tags: ["Buckets"], summary: "Upload object", request: { body: { content: { "application/octet-stream": { schema: z11.object({}).openapi({ type: "string", format: "binary" }) } } }, params: z11.object({ bucket: z11.string() }), query: z11.object({ key: z11.string().describe("base64 encoded file key"), customMetadata: z11.string().nullable().optional().describe("base64 encoded json string"), httpMetadata: z11.string().nullable().optional().describe("base64 encoded json string") }) } }; async handle(c) { const data = await this.getValidatedData(); const bucket = c.env[data.params.bucket]; const key = decodeURIComponent(escape(atob(data.query.key))); let customMetadata = void 0; if (data.query.customMetadata) { customMetadata = JSON.parse( decodeURIComponent(escape(atob(data.query.customMetadata))) ); } let httpMetadata = void 0; if (data.query.httpMetadata) { httpMetadata = JSON.parse( decodeURIComponent(escape(atob(data.query.httpMetadata))) ); } return await bucket.put(key, c.req.raw.body, { customMetadata, httpMetadata }); } }; // src/modules/dashboard.ts function dashboardIndex(c) { if (c.env.ASSETS === void 0) { return c.text( "ASSETS binding is not defined, learn more here: https://r2explorer.dev/guides/migrating-to-1.1/", 500 ); } return c.text( "ASSETS binding is not pointing to a valid dashboard, learn more here: https://r2explorer.dev/guides/migrating-to-1.1/", 500 ); } async function dashboardRedirect(c, next) { if (c.env.ASSETS === void 0) { return c.text( "ASSETS binding is not defined, learn more here: https://r2explorer.dev/guides/migrating-to-1.1/", 500 ); } const url = new URL(c.req.url); if (!url.pathname.includes(".")) { return c.env.ASSETS.fetch(new Request(url.origin)); } await next(); } // src/modules/emails/receiveEmail.ts import PostalMime from "postal-mime"; // src/foundation/dates.ts function getCurrentTimestampMilliseconds() { return Math.floor(Date.now()); } // src/modules/emails/receiveEmail.ts async function streamToArrayBuffer(stream, streamSize) { const result = new Uint8Array(streamSize); let bytesRead = 0; const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) { break; } result.set(value, bytesRead); bytesRead += value.length; } return result; } async function receiveEmail(event, env, ctx, config) { let bucket; if (config?.emailRouting?.targetBucket && env[config.emailRouting.targetBucket]) { bucket = env[config.emailRouting.targetBucket]; } if (!bucket) { for (const [key, value] of Object.entries(env)) { if (value.get && value.put) { bucket = value; break; } } } const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize); const parser = new PostalMime(); const parsedEmail = await parser.parse(rawEmail); const emailPath = `${getCurrentTimestampMilliseconds()}-${crypto.randomUUID()}`; await bucket.put( `.r2-explorer/emails/inbox/${emailPath}.json`, JSON.stringify(parsedEmail), { customMetadata: { subject: parsedEmail.subject, from_address: parsedEmail.from?.address, from_name: parsedEmail.from?.name, to_address: parsedEmail.to.length > 0 ? parsedEmail.to[0].address : null, to_name: parsedEmail.to.length > 0 ? parsedEmail.to[0].name : null, has_attachments: parsedEmail.attachments.length > 0, read: false, timestamp: Date.now() } } ); for (const att of parsedEmail.attachments) { await bucket.put( `.r2-explorer/emails/inbox/${emailPath}/${att.filename}`, att.content ); } } // src/modules/emails/sendEmail.ts import { OpenAPIRoute as OpenAPIRoute12, Str } from "chanfana"; import { z as z12 } from "zod"; var SendEmail = class extends OpenAPIRoute12 { schema = { operationId: "post-email-send", tags: ["Emails"], summary: "Send Email", request: { body: { content: { "application/json": { schema: z12.object({ subject: Str({ example: "Look! No servers" }), from: z12.object({ email: Str({ example: "sender@example.com" }), name: Str({ example: "Workers - MailChannels integration" }) }), to: z12.object({ email: Str({ example: "test@example.com" }), name: Str({ example: "Test Recipient" }) }).array(), content: z12.object({}).catchall(z12.string()) }) } } } } }; async handle(c) { if (c.get("config").readonly === true) return Response.json({ msg: "unauthorized" }, { status: 401 }); return { success: false, error: "unavailable" }; } }; // src/modules/server/getInfo.ts import { OpenAPIRoute as OpenAPIRoute13 } from "chanfana"; var GetInfo = class extends OpenAPIRoute13 { schema = { operationId: "get-server-info", tags: ["Server"], summary: "Get server info" }; async handle(c) { const { basicAuth: basicAuth2, ...config } = c.get("config"); const buckets = []; for (const [key, value] of Object.entries(c.env)) { if (value.get && value.put && value.get.toString().includes("function") && value.put.toString().includes("function")) { buckets.push({ name: key }); } } return { version: settings.version, config, auth: c.get("authentication_type") ? { type: c.get("authentication_type"), username: c.get("authentication_username") } : void 0, buckets }; } }; // src/index.ts function R2Explorer(config) { extendZodWithOpenApi(z13); config = config || {}; if (config.readonly !== false) config.readonly = true; const openapiSchema = { openapi: "3.1.0", info: { title: "R2 Explorer API", version: settings.version } }; if (config.basicAuth) { openapiSchema["security"] = [ { basicAuth: [] } ]; } const app = new Hono(); app.use("*", async (c, next) => { c.set("config", config); await next(); }); const openapi = fromHono(app, { schema: openapiSchema, raiseUnknownParameters: true, generateOperationIds: false }); if (config.cors === true) { app.use("/api/*", cors()); } if (config.readonly === true) { app.use("/api/*", readOnlyMiddleware); } if (config.cfAccessTeamName) { app.use("/api/*", cloudflareAccess(config.cfAccessTeamName)); app.use("/api/*", async (c, next) => { c.set("authentication_type", "cloudflare-access"); c.set("authentication_username", c.get("accessPayload").email); await next(); }); } if (config.basicAuth) { openapi.registry.registerComponent("securitySchemes", "basicAuth", { type: "http", scheme: "basic" }); app.use( "/api/*", basicAuth({ invalidUserMessage: "Authentication error: Basic Auth required", verifyUser: (username, password, c) => { const users = Array.isArray(c.get("config").basicAuth) ? c.get("config").basicAuth : [c.get("config").basicAuth]; for (const user of users) { if (user.username === username && user.password === password) { c.set("authentication_type", "basic-auth"); c.set("authentication_username", username); return true; } } return false; } }) ); } openapi.get("/api/server/config", GetInfo); openapi.get("/api/buckets/:bucket", ListObjects); openapi.post("/api/buckets/:bucket/move", MoveObject); openapi.post("/api/buckets/:bucket/folder", CreateFolder); openapi.post("/api/buckets/:bucket/upload", PutObject); openapi.post("/api/buckets/:bucket/multipart/create", CreateUpload); openapi.post("/api/buckets/:bucket/multipart/upload", PartUpload); openapi.post("/api/buckets/:bucket/multipart/complete", CompleteUpload); openapi.post("/api/buckets/:bucket/delete", DeleteObject); openapi.on("head", "/api/buckets/:bucket/:key", HeadObject); openapi.get("/api/buckets/:bucket/:key/head", HeadObject); openapi.get("/api/buckets/:bucket/:key", GetObject); openapi.post("/api/buckets/:bucket/:key", PutMetadata); openapi.post("/api/emails/send", SendEmail); openapi.get("/", dashboardIndex); openapi.get("*", dashboardRedirect); app.all( "*", () => Response.json({ msg: "404, not found!" }, { status: 404 }) ); return { // TODO: improve event type async email(event, env, context) { await receiveEmail(event, env, context, config); }, async fetch(request, env, context) { return app.fetch(request, env, context); } }; } export { R2Explorer };