r2-explorer
Version:
A Google Drive Interface for your Cloudflare R2 Buckets
784 lines (761 loc) • 24.7 kB
JavaScript
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
});