Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,59 @@ describe("GCS endpoint conformance tests", () => {

expect(returnedMetadata.contentType).to.equal("text/plain");
});

emulatorOnly.it(
"should handle signed post policy uploads via the emulator endpoint",
async () => {
const boundary = "signed-post-policy-boundary";
const startBuffer = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
${TEST_FILE_NAME}\r
--${boundary}\r
Content-Disposition: form-data; name="Content-Type"\r
\r
text/plain\r
--${boundary}\r
Content-Disposition: form-data; name="Cache-Control"\r
\r
public, max-age=60\r
--${boundary}\r
Content-Disposition: form-data; name="Content-Disposition"\r
\r
inline\r
--${boundary}\r
Content-Disposition: form-data; name="x-goog-meta-color"\r
\r
blue\r
--${boundary}\r
Content-Disposition: form-data; name="file"; filename="testFile"\r
Content-Type: text/plain\r
\r
`);
const endBuffer = Buffer.from(`\r
--${boundary}--\r
`);
const body = Buffer.concat([startBuffer, Buffer.from("hello world"), endBuffer]);

await supertest(storageHost)
.post(`/${storageBucket}`)
.set("content-type", `multipart/form-data; boundary=${boundary}`)
.send(body)
.expect(204);

const metadata = await supertest(storageHost)
.get(`/storage/v1/b/${storageBucket}/o/${ENCODED_TEST_FILE_NAME}`)
.expect(200)
.then((res) => res.body);

expect(metadata.name).to.equal(TEST_FILE_NAME);
expect(metadata.contentType).to.equal("text/plain");
expect(metadata.cacheControl).to.equal("public, max-age=60");
expect(metadata.contentDisposition).to.equal("inline");
expect(metadata.metadata).to.deep.equal({ color: "blue" });
},
);
});
});

Expand Down
73 changes: 72 additions & 1 deletion src/emulator/storage/apis/gcloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { StorageEmulator } from "../index";
import { EmulatorLogger } from "../../emulatorLogger";
import { GetObjectResponse, ListObjectsResponse } from "../files";
import type { Request, Response } from "express";
import { parseObjectUploadMultipartRequest } from "../multipart";
import { parseFormDataMultipartRequest, parseObjectUploadMultipartRequest } from "../multipart";
import { Upload, UploadNotActiveError } from "../upload";
import { ForbiddenError, NotFoundError } from "../errors";
import { reqBodyToBuffer } from "../../shared/request";
Expand Down Expand Up @@ -368,6 +368,77 @@ export function createCloudEndpoints(emulator: StorageEmulator): Router {
return sendFileBytes(getObjectResponse.metadata, getObjectResponse.data, req, res);
});

gcloudStorageAPI.post(
"/:bucketId",
(req, res, next) => {
adminStorageLayer.createBucket(req.params.bucketId);
next();
},
async (req, res) => {
const contentTypeHeader = req.header("content-type");
if (!contentTypeHeader?.includes("multipart/form-data")) {
return res.status(400).send("Content-Type must be multipart/form-data");
}

try {
const bodyBuffer = await reqBodyToBuffer(req);

const formData = parseFormDataMultipartRequest(contentTypeHeader, bodyBuffer);

const keyPart = formData.find((p) => p.name === "key");
const filePart = formData.find((p) => p.type === "file");

if (keyPart?.type !== "field" || filePart?.type !== "file") {
return res.status(400).send("Missing 'key' or file.");
}

const metadata: IncomingMetadata = {
contentType: filePart.contentType,
metadata: {},
};

const HEADER_MAP: Record<string, keyof IncomingMetadata> = {
"content-type": "contentType",
"cache-control": "cacheControl",
"content-disposition": "contentDisposition",
"content-encoding": "contentEncoding",
"content-language": "contentLanguage",
};
for (const part of formData) {
if (part.type === "file" || part.name === "key") continue;
const key = part.name.toLowerCase();
if (key.startsWith("x-goog-meta-")) {
metadata.metadata![key.substring(12)] = part.value;
} else if (HEADER_MAP[key]) {
(metadata[HEADER_MAP[key]] as string) = part.value.trim();
}
}
Comment thread
7hokerz marked this conversation as resolved.

const upload = uploadService.multipartUpload({
bucketId: req.params.bucketId,
objectId: keyPart.value,
dataRaw: filePart.data,
metadata: metadata,
authorization: req.header("authorization"),
});

await adminStorageLayer.uploadObject(upload);
return res.sendStatus(204);
} catch (err) {
if (err instanceof ForbiddenError) {
return res.sendStatus(403);
}
if (err instanceof NotFoundError) {
return res.sendStatus(404);
}
if (err instanceof Error) {
return res.status(400).send(err.message);
}
throw err;
}
},
);

gcloudStorageAPI.post(
"/b/:bucketId/o/:objectId/:method(rewriteTo|copyTo)/b/:destBucketId/o/:destObjectId",
(req, res, next) => {
Expand Down
93 changes: 92 additions & 1 deletion src/emulator/storage/multipart.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { expect } from "chai";
import { parseObjectUploadMultipartRequest } from "./multipart";
import {
parseObjectUploadMultipartRequest,
parseFormDataMultipartRequest,
MultipartFile,
MultipartField,
} from "./multipart";
import { randomBytes } from "crypto";

describe("Storage Multipart Request Parser", () => {
Expand Down Expand Up @@ -142,4 +147,90 @@ hello there!
);
});
});

describe("#parseFormDataMultipartRequest()", () => {
const boundary = "b1d5b2e3-1845-4338-9400-6ac07ce53c1e";
const CONTENT_TYPE_HEADER = `multipart/form-data; boundary=${boundary}`;

const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const imageContent = Buffer.concat([pngSignature, randomBytes(100)]);
const startBuffer = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
path/image.png\r
--${boundary}\r
Content-Disposition: form-data; name="content-type"\r
\r
image/png\r
--${boundary}\r
Content-Disposition: form-data; name="x-goog-meta-color"\r
\r
blue\r
--${boundary}\r
Content-Disposition: form-data; name="file"; filename="image.png"\r
Content-Type: image/png\r
\r
`);
const endBuffer = Buffer.from(`\r
--${boundary}--\r
`);
const BODY = Buffer.concat([startBuffer, imageContent, endBuffer]);

it("parses a form data multipart request with file and fields successfully", () => {
const parts = parseFormDataMultipartRequest(CONTENT_TYPE_HEADER, BODY);

expect(parts.length).to.equal(4);

const keyPart = parts.find((p) => p.name === "key") as MultipartField;
expect(keyPart.value).to.equal("path/image.png");

const contentTypePart = parts.find((p) => p.name === "content-type") as MultipartField;
expect(contentTypePart.value).to.equal("image/png");

const metadataPart = parts.find((p) => p.name === "x-goog-meta-color") as MultipartField;
expect(metadataPart.value).to.equal("blue");

const filePart = parts.find((p) => p.name === "file") as MultipartFile;
expect(filePart.filename).to.equal("image.png");
expect(filePart.contentType).to.equal("image/png");
expect(filePart.data.byteLength).to.equal(imageContent.byteLength);
});

it("fails to parse a form data multipart request when file part is missing name", () => {
const invalidBody = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
path/image.png\r
--${boundary}\r
Content-Disposition: form-data; filename="image.png"\r
Content-Type: image/png\r
\r
hello there!\r
--${boundary}--\r
`);

expect(() => parseFormDataMultipartRequest(CONTENT_TYPE_HEADER, invalidBody)).to.throw(
"Missing 'name' in Content-Disposition header.",
);
});

it("fails to parse a form data multipart request when part body is missing trailing line separator", () => {
const invalidStartBuffer = Buffer.from(`--${boundary}\r
Content-Disposition: form-data; name="key"\r
\r
path/image.png\r
--${boundary}\r
Content-Disposition: form-data; name="file"; filename="image.png"\r
Content-Type: image/png\r
\r
`);
const invalidEndBuffer = Buffer.from(`--${boundary}--\r
`);
const invalidBody = Buffer.concat([invalidStartBuffer, imageContent, invalidEndBuffer]);

expect(() => parseFormDataMultipartRequest(CONTENT_TYPE_HEADER, invalidBody)).to.throw(
"Missing trailing line separator.",
);
});
});
});
Loading