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 ): Promise { 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 = {}; 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 ): Promise { 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 ): Promise { 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 ): Promise { 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 ): Promise { 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 | 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}"`, }, }); } }