205 lines
6.2 KiB
TypeScript
205 lines
6.2 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import type { S3Context } from '../classes/context.js';
|
|
|
|
/**
|
|
* Object-level operations
|
|
*/
|
|
export class ObjectController {
|
|
/**
|
|
* PUT /:bucket/:key* - Upload object or copy object
|
|
*/
|
|
public static async putObject(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse,
|
|
ctx: S3Context,
|
|
params: Record<string, string>
|
|
): Promise<void> {
|
|
const { bucket, key } = params;
|
|
|
|
// Check if this is a COPY operation
|
|
const copySource = ctx.headers['x-amz-copy-source'] as string | undefined;
|
|
if (copySource) {
|
|
return ObjectController.copyObject(req, res, ctx, params);
|
|
}
|
|
|
|
// Extract metadata from headers
|
|
const metadata: Record<string, string> = {};
|
|
for (const [header, value] of Object.entries(ctx.headers)) {
|
|
if (header.startsWith('x-amz-meta-')) {
|
|
metadata[header] = value as string;
|
|
}
|
|
if (header === 'content-type' && value) {
|
|
metadata['content-type'] = value as string;
|
|
}
|
|
if (header === 'cache-control' && value) {
|
|
metadata['cache-control'] = value as string;
|
|
}
|
|
}
|
|
|
|
// If no content-type, default to binary/octet-stream
|
|
if (!metadata['content-type']) {
|
|
metadata['content-type'] = 'binary/octet-stream';
|
|
}
|
|
|
|
// Stream upload
|
|
const result = await ctx.store.putObject(bucket, key, ctx.getRequestStream(), metadata);
|
|
|
|
ctx.setHeader('ETag', `"${result.md5}"`);
|
|
ctx.status(200).send('');
|
|
}
|
|
|
|
/**
|
|
* GET /:bucket/:key* - Download object
|
|
*/
|
|
public static async getObject(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse,
|
|
ctx: S3Context,
|
|
params: Record<string, string>
|
|
): Promise<void> {
|
|
const { bucket, key } = params;
|
|
|
|
// Parse Range header if present
|
|
const rangeHeader = ctx.headers.range as string | undefined;
|
|
let range: { start: number; end: number } | undefined;
|
|
|
|
if (rangeHeader) {
|
|
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
|
if (matches) {
|
|
const start = parseInt(matches[1]);
|
|
const end = matches[2] ? parseInt(matches[2]) : undefined;
|
|
range = { start, end: end || start + 1024 * 1024 }; // Default to 1MB if no end
|
|
}
|
|
}
|
|
|
|
// Get object
|
|
const object = await ctx.store.getObject(bucket, key, range);
|
|
|
|
// Set response headers
|
|
ctx.setHeader('ETag', `"${object.md5}"`);
|
|
ctx.setHeader('Last-Modified', object.lastModified.toUTCString());
|
|
ctx.setHeader('Content-Type', object.metadata['content-type'] || 'binary/octet-stream');
|
|
ctx.setHeader('Accept-Ranges', 'bytes');
|
|
|
|
// Handle custom metadata headers
|
|
for (const [key, value] of Object.entries(object.metadata)) {
|
|
if (key.startsWith('x-amz-meta-')) {
|
|
ctx.setHeader(key, value);
|
|
}
|
|
}
|
|
|
|
if (range) {
|
|
ctx.status(206);
|
|
ctx.setHeader('Content-Length', (range.end - range.start + 1).toString());
|
|
ctx.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${object.size}`);
|
|
} else {
|
|
ctx.status(200);
|
|
ctx.setHeader('Content-Length', object.size.toString());
|
|
}
|
|
|
|
// Stream response
|
|
await ctx.send(object.content!);
|
|
}
|
|
|
|
/**
|
|
* HEAD /:bucket/:key* - Get object metadata
|
|
*/
|
|
public static async headObject(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse,
|
|
ctx: S3Context,
|
|
params: Record<string, string>
|
|
): Promise<void> {
|
|
const { bucket, key } = params;
|
|
|
|
// Get object (without content)
|
|
const object = await ctx.store.getObject(bucket, key);
|
|
|
|
// Set response headers (same as GET but no body)
|
|
ctx.setHeader('ETag', `"${object.md5}"`);
|
|
ctx.setHeader('Last-Modified', object.lastModified.toUTCString());
|
|
ctx.setHeader('Content-Type', object.metadata['content-type'] || 'binary/octet-stream');
|
|
ctx.setHeader('Content-Length', object.size.toString());
|
|
ctx.setHeader('Accept-Ranges', 'bytes');
|
|
|
|
// Handle custom metadata headers
|
|
for (const [key, value] of Object.entries(object.metadata)) {
|
|
if (key.startsWith('x-amz-meta-')) {
|
|
ctx.setHeader(key, value);
|
|
}
|
|
}
|
|
|
|
ctx.status(200).send('');
|
|
}
|
|
|
|
/**
|
|
* DELETE /:bucket/:key* - Delete object
|
|
*/
|
|
public static async deleteObject(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse,
|
|
ctx: S3Context,
|
|
params: Record<string, string>
|
|
): Promise<void> {
|
|
const { bucket, key } = params;
|
|
|
|
await ctx.store.deleteObject(bucket, key);
|
|
ctx.status(204).send('');
|
|
}
|
|
|
|
/**
|
|
* COPY operation (PUT with x-amz-copy-source header)
|
|
*/
|
|
private static async copyObject(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse,
|
|
ctx: S3Context,
|
|
params: Record<string, string>
|
|
): Promise<void> {
|
|
const { bucket: destBucket, key: destKey } = params;
|
|
const copySource = ctx.headers['x-amz-copy-source'] as string;
|
|
|
|
// Parse source bucket and key from copy source
|
|
// Format: /bucket/key or bucket/key
|
|
const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource;
|
|
const firstSlash = sourcePath.indexOf('/');
|
|
const srcBucket = decodeURIComponent(sourcePath.slice(0, firstSlash));
|
|
const srcKey = decodeURIComponent(sourcePath.slice(firstSlash + 1));
|
|
|
|
// Get metadata directive (COPY or REPLACE)
|
|
const metadataDirective = (ctx.headers['x-amz-metadata-directive'] as string)?.toUpperCase() || 'COPY';
|
|
|
|
// Extract new metadata if REPLACE
|
|
let newMetadata: Record<string, string> | undefined;
|
|
if (metadataDirective === 'REPLACE') {
|
|
newMetadata = {};
|
|
for (const [header, value] of Object.entries(ctx.headers)) {
|
|
if (header.startsWith('x-amz-meta-')) {
|
|
newMetadata[header] = value as string;
|
|
}
|
|
if (header === 'content-type' && value) {
|
|
newMetadata['content-type'] = value as string;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Perform copy
|
|
const result = await ctx.store.copyObject(
|
|
srcBucket,
|
|
srcKey,
|
|
destBucket,
|
|
destKey,
|
|
metadataDirective as 'COPY' | 'REPLACE',
|
|
newMetadata
|
|
);
|
|
|
|
// Send XML response
|
|
await ctx.sendXML({
|
|
CopyObjectResult: {
|
|
LastModified: new Date().toISOString(),
|
|
ETag: `"${result.md5}"`,
|
|
},
|
|
});
|
|
}
|
|
}
|