mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
feat: add the openapi docs
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, PutObjectCommand, S3Client, UploadPartCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
interface Env {
|
||||
IRIS_ENDPOINT: string;
|
||||
IRIS_ACCESS_KEY: string;
|
||||
IRIS_SECRET_KEY: string;
|
||||
IRIS_REGION: string;
|
||||
IRIS_BUCKET: string;
|
||||
}
|
||||
|
||||
interface PresignRequest {
|
||||
key: string;
|
||||
contentType?: string;
|
||||
operation?: "put" | "createMultipart" | "uploadPart" | "completeMultipart" | "abortMultipart";
|
||||
uploadId?: string;
|
||||
partNumber?: number;
|
||||
parts?: Array<{ ETag: string; PartNumber: number }>;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function completionXml(parts: Array<{ ETag: string; PartNumber: number }> | undefined): string | null {
|
||||
if (!parts?.length || parts.some((part, index) => !Number.isInteger(part.PartNumber) || part.PartNumber !== index + 1 || typeof part.ETag !== "string" || part.ETag.length === 0)) return null;
|
||||
return `<CompleteMultipartUpload>${parts.map((part) => `<Part><PartNumber>${part.PartNumber}</PartNumber><ETag>${escapeXml(part.ETag)}</ETag></Part>`).join("")}</CompleteMultipartUpload>`;
|
||||
}
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
function s3(env: Env): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: env.IRIS_ENDPOINT,
|
||||
region: env.IRIS_REGION,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: env.IRIS_ACCESS_KEY, secretAccessKey: env.IRIS_SECRET_KEY },
|
||||
});
|
||||
}
|
||||
|
||||
function validKey(key: unknown): key is string {
|
||||
return typeof key === "string" && key.length > 0 && key.length <= 1024 && !key.startsWith("/") && !key.split("/").includes("..");
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== "POST" || new URL(request.url).pathname !== "/presign") return new Response("Not found", { status: 404 });
|
||||
|
||||
let input: PresignRequest;
|
||||
try {
|
||||
input = await request.json<PresignRequest>();
|
||||
} catch {
|
||||
return json({ error: "Expected JSON body" }, 400);
|
||||
}
|
||||
if (!validKey(input.key)) return json({ error: "Invalid object key" }, 400);
|
||||
|
||||
const client = s3(env);
|
||||
const operation = input.operation ?? "put";
|
||||
const expiresIn = 5 * 60;
|
||||
|
||||
if (operation === "put") {
|
||||
const contentType = input.contentType || "application/octet-stream";
|
||||
const url = await getSignedUrl(client, new PutObjectCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn });
|
||||
return json({ url, method: "PUT", headers: { "Content-Type": contentType }, expiresIn });
|
||||
}
|
||||
|
||||
if (operation === "createMultipart") {
|
||||
const contentType = input.contentType || "application/octet-stream";
|
||||
const url = await getSignedUrl(client, new CreateMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn });
|
||||
return json({ url, method: "POST", headers: { "Content-Type": contentType }, expiresIn });
|
||||
}
|
||||
|
||||
if (!input.uploadId) return json({ error: "uploadId is required" }, 400);
|
||||
if (operation === "uploadPart") {
|
||||
const partNumber = input.partNumber;
|
||||
if (!Number.isInteger(partNumber) || partNumber === undefined || partNumber < 1 || partNumber > 10_000) return json({ error: "partNumber must be 1 through 10000" }, 400);
|
||||
const url = await getSignedUrl(client, new UploadPartCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId, PartNumber: partNumber }), { expiresIn });
|
||||
return json({ url, method: "PUT", expiresIn });
|
||||
}
|
||||
if (operation === "completeMultipart") {
|
||||
const body = completionXml(input.parts);
|
||||
if (!body) return json({ error: "parts must have sequential PartNumber values beginning at 1 and non-empty ETag values" }, 400);
|
||||
const url = await getSignedUrl(client, new CompleteMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId, MultipartUpload: { Parts: input.parts } }), { expiresIn });
|
||||
return json({ url, method: "POST", headers: { "Content-Type": "application/xml" }, body, expiresIn });
|
||||
}
|
||||
if (operation === "abortMultipart") {
|
||||
const url = await getSignedUrl(client, new AbortMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId }), { expiresIn });
|
||||
return json({ url, method: "DELETE", expiresIn });
|
||||
}
|
||||
|
||||
return json({ error: "Unsupported operation" }, 400);
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
@@ -0,0 +1,48 @@
|
||||
interface PresignResponse {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
async function presign(body: Record<string, unknown>): Promise<PresignResponse> {
|
||||
const response = await fetch("/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json<PresignResponse>();
|
||||
}
|
||||
|
||||
function uploadIdFromXml(xml: string): string {
|
||||
const value = /<UploadId>([^<]+)<\/UploadId>/.exec(xml)?.[1];
|
||||
if (!value) throw new Error("CreateMultipartUpload did not return UploadId");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Uploads parts in order. I.R.I.S. does not allow parallel or out-of-order part uploads. */
|
||||
export async function uploadLargeFile(file: File, key: string, partSize = 16 * 1024 * 1024): Promise<void> {
|
||||
const contentType = file.type || "application/octet-stream";
|
||||
const create = await presign({ key, contentType, operation: "createMultipart" });
|
||||
const createResponse = await fetch(create.url, { method: create.method, headers: create.headers });
|
||||
if (!createResponse.ok) throw new Error(await createResponse.text());
|
||||
const uploadId = uploadIdFromXml(await createResponse.text());
|
||||
|
||||
const parts: Array<{ ETag: string; PartNumber: number }> = [];
|
||||
for (let index = 0, offset = 0; offset < file.size; index++, offset += partSize) {
|
||||
const partNumber = index + 1;
|
||||
const body = file.slice(offset, Math.min(offset + partSize, file.size));
|
||||
const signed = await presign({ key, uploadId, partNumber, operation: "uploadPart" });
|
||||
const response = await fetch(signed.url, { method: signed.method, body });
|
||||
if (!response.ok) throw new Error(`Part ${partNumber} failed: ${await response.text()}`);
|
||||
const etag = response.headers.get("ETag");
|
||||
if (!etag) throw new Error(`Part ${partNumber} did not return ETag; check CORS_ALLOWED_ORIGINS`);
|
||||
parts.push({ ETag: etag, PartNumber: partNumber });
|
||||
}
|
||||
|
||||
const complete = await presign({ key, uploadId, parts, operation: "completeMultipart" });
|
||||
if (!complete.body) throw new Error("BFF did not return the completion XML body");
|
||||
const completeResponse = await fetch(complete.url, { method: complete.method, headers: complete.headers, body: complete.body });
|
||||
if (!completeResponse.ok) throw new Error(await completeResponse.text());
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface PresignedPut {
|
||||
url: string;
|
||||
method: "PUT";
|
||||
headers: { "Content-Type": string };
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
async function getPresignedPut(key: string, contentType: string): Promise<PresignedPut> {
|
||||
const response = await fetch("/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, contentType, operation: "put" }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json<PresignedPut>();
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, key: string): Promise<string | null> {
|
||||
const signed = await getPresignedPut(key, file.type || "application/octet-stream");
|
||||
const response = await fetch(signed.url, {
|
||||
method: signed.method,
|
||||
headers: signed.headers,
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Upload failed: ${await response.text()}`);
|
||||
|
||||
// Requires CORS_ALLOWED_ORIGINS to include this page's exact origin.
|
||||
return response.headers.get("ETag");
|
||||
}
|
||||
Reference in New Issue
Block a user