UNPKG

r2-explorer

Version:

A Google Drive Interface for your Cloudflare R2 Buckets

784 lines (761 loc) 24.7 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { R2Explorer: () => R2Explorer }); module.exports = __toCommonJS(src_exports); var import_cloudflare_access = require("@hono/cloudflare-access"); var import_chanfana14 = require("chanfana"); var import_hono = require("hono"); var import_basic_auth = require("hono/basic-auth"); var import_cors = require("hono/cors"); var import_zod13 = require("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 var import_chanfana = require("chanfana"); var import_zod = require("zod"); var CreateFolder = class extends import_chanfana.OpenAPIRoute { schema = { operationId: "post-bucket-create-folder", tags: ["Buckets"], summary: "Create folder", request: { params: import_zod.z.object({ bucket: import_zod.z.string() }), body: { content: { "application/json": { schema: import_zod.z.object({ key: import_zod.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 var import_chanfana2 = require("chanfana"); var import_zod2 = require("zod"); var DeleteObject = class extends import_chanfana2.OpenAPIRoute { schema = { operationId: "post-bucket-delete-object", tags: ["Buckets"], summary: "Delete object", request: { params: import_zod2.z.object({ bucket: import_zod2.z.string() }), body: { content: { "application/json": { schema: import_zod2.z.object({ key: import_zod2.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))); await bucket.delete(key); return { success: true }; } }; // src/modules/buckets/getObject.ts var import_chanfana3 = require("chanfana"); var import_zod3 = require("zod"); var GetObject = class extends import_chanfana3.OpenAPIRoute { schema = { operationId: "get-bucket-object", tags: ["Buckets"], summary: "Get Object", request: { params: import_zod3.z.object({ bucket: import_zod3.z.string(), key: import_zod3.z.string().describe("base64 encoded file key") }) }, responses: { "200": { description: "File binary", schema: import_zod3.z.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 var import_chanfana4 = require("chanfana"); var import_zod4 = require("zod"); var HeadObject = class extends import_chanfana4.OpenAPIRoute { schema = { operationId: "Head-bucket-object", tags: ["Buckets"], summary: "Get Object", request: { params: import_zod4.z.object({ bucket: import_zod4.z.string(), key: import_zod4.z.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 var import_chanfana5 = require("chanfana"); var import_zod5 = require("zod"); var ListObjects = class extends import_chanfana5.OpenAPIRoute { schema = { operationId: "get-bucket-list-objects", tags: ["Buckets"], summary: "List objects", request: { params: import_zod5.z.object({ bucket: import_zod5.z.string() }), query: import_zod5.z.object({ limit: import_zod5.z.number().optional(), prefix: import_zod5.z.string().nullable().optional().describe("base64 encoded prefix"), cursor: import_zod5.z.string().nullable().optional(), delimiter: import_zod5.z.string().nullable().optional(), startAfter: import_zod5.z.string().nullable().optional(), include: import_zod5.z.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 var import_chanfana6 = require("chanfana"); var import_zod6 = require("zod"); var MoveObject = class extends import_chanfana6.OpenAPIRoute { schema = { operationId: "post-bucket-move-object", tags: ["Buckets"], summary: "Move object", request: { params: import_zod6.z.object({ bucket: import_zod6.z.string() }), body: { content: { "application/json": { schema: import_zod6.z.object({ oldKey: import_zod6.z.string().describe("base64 encoded file key"), newKey: import_zod6.z.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 var import_chanfana7 = require("chanfana"); var import_zod7 = require("zod"); var CompleteUpload = class extends import_chanfana7.OpenAPIRoute { schema = { operationId: "post-multipart-complete-upload", tags: ["Multipart"], summary: "Complete upload", request: { params: import_zod7.z.object({ bucket: import_zod7.z.string() }), body: { content: { "application/json": { schema: import_zod7.z.object({ uploadId: import_zod7.z.string(), parts: import_zod7.z.object({ etag: import_zod7.z.string(), partNumber: import_zod7.z.number().int() }).array(), key: import_zod7.z.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 var import_chanfana8 = require("chanfana"); var import_zod8 = require("zod"); var CreateUpload = class extends import_chanfana8.OpenAPIRoute { schema = { operationId: "post-multipart-create-upload", tags: ["Multipart"], summary: "Create upload", request: { params: import_zod8.z.object({ bucket: import_zod8.z.string() }), query: import_zod8.z.object({ key: import_zod8.z.string().describe("base64 encoded file key"), customMetadata: import_zod8.z.string().nullable().optional().describe("base64 encoded json string"), httpMetadata: import_zod8.z.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 var import_chanfana9 = require("chanfana"); var import_zod9 = require("zod"); var PartUpload = class extends import_chanfana9.OpenAPIRoute { schema = { operationId: "post-multipart-part-upload", tags: ["Multipart"], summary: "Part upload", request: { body: { content: { "application/octet-stream": { schema: import_zod9.z.object({}).openapi({ type: "string", format: "binary" }) } } }, params: import_zod9.z.object({ bucket: import_zod9.z.string() }), query: import_zod9.z.object({ key: import_zod9.z.string().describe("base64 encoded file key"), uploadId: import_zod9.z.string(), partNumber: import_zod9.z.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 var import_chanfana10 = require("chanfana"); var import_zod10 = require("zod"); var PutMetadata = class extends import_chanfana10.OpenAPIRoute { schema = { operationId: "post-bucket-put-object-metadata", tags: ["Buckets"], summary: "Update object metadata", request: { params: import_zod10.z.object({ bucket: import_zod10.z.string(), key: import_zod10.z.string().describe("base64 encoded file key") }), body: { content: { "application/json": { schema: import_zod10.z.object({ customMetadata: import_zod10.z.record(import_zod10.z.string(), import_zod10.z.any()), httpMetadata: import_zod10.z.record(import_zod10.z.string(), import_zod10.z.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 var import_chanfana11 = require("chanfana"); var import_zod11 = require("zod"); var PutObject = class extends import_chanfana11.OpenAPIRoute { schema = { operationId: "post-bucket-upload-object", tags: ["Buckets"], summary: "Upload object", request: { body: { content: { "application/octet-stream": { schema: import_zod11.z.object({}).openapi({ type: "string", format: "binary" }) } } }, params: import_zod11.z.object({ bucket: import_zod11.z.string() }), query: import_zod11.z.object({ key: import_zod11.z.string().describe("base64 encoded file key"), customMetadata: import_zod11.z.string().nullable().optional().describe("base64 encoded json string"), httpMetadata: import_zod11.z.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 var import_postal_mime = __toESM(require("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 import_postal_mime.default(); 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 var import_chanfana12 = require("chanfana"); var import_zod12 = require("zod"); var SendEmail = class extends import_chanfana12.OpenAPIRoute { schema = { operationId: "post-email-send", tags: ["Emails"], summary: "Send Email", request: { body: { content: { "application/json": { schema: import_zod12.z.object({ subject: (0, import_chanfana12.Str)({ example: "Look! No servers" }), from: import_zod12.z.object({ email: (0, import_chanfana12.Str)({ example: "sender@example.com" }), name: (0, import_chanfana12.Str)({ example: "Workers - MailChannels integration" }) }), to: import_zod12.z.object({ email: (0, import_chanfana12.Str)({ example: "test@example.com" }), name: (0, import_chanfana12.Str)({ example: "Test Recipient" }) }).array(), content: import_zod12.z.object({}).catchall(import_zod12.z.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 var import_chanfana13 = require("chanfana"); var GetInfo = class extends import_chanfana13.OpenAPIRoute { 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) { (0, import_chanfana14.extendZodWithOpenApi)(import_zod13.z); 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 import_hono.Hono(); app.use("*", async (c, next) => { c.set("config", config); await next(); }); const openapi = (0, import_chanfana14.fromHono)(app, { schema: openapiSchema, raiseUnknownParameters: true, generateOperationIds: false }); if (config.cors === true) { app.use("/api/*", (0, import_cors.cors)()); } if (config.readonly === true) { app.use("/api/*", readOnlyMiddleware); } if (config.cfAccessTeamName) { app.use("/api/*", (0, import_cloudflare_access.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/*", (0, import_basic_auth.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); } }; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { R2Explorer });