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