import * as plugins from '../plugins.js'; import { S3Router } from './router.js'; import { MiddlewareStack } from './middleware-stack.js'; import { S3Context } from './context.js'; import { FilesystemStore } from './filesystem-store.js'; import { S3Error } from './s3-error.js'; import { Logger } from './logger.js'; import { MultipartUploadManager } from './multipart-manager.js'; import { ServiceController } from '../controllers/service.controller.js'; import { BucketController } from '../controllers/bucket.controller.js'; import { ObjectController } from '../controllers/object.controller.js'; import type { ISmarts3Config } from '../index.js'; export interface ISmarts3ServerOptions { port?: number; address?: string; directory?: string; cleanSlate?: boolean; silent?: boolean; config?: Required; } /** * Custom S3-compatible server implementation * Built on native Node.js http module with zero framework dependencies */ export class Smarts3Server { private httpServer?: plugins.http.Server; private router: S3Router; private middlewares: MiddlewareStack; public store: FilesystemStore; // Made public for direct access from Smarts3 class public multipart: MultipartUploadManager; // Made public for controller access private options: Required>; private config: Required; private logger: Logger; constructor(options: ISmarts3ServerOptions = {}) { this.options = { port: options.port ?? 3000, address: options.address ?? '0.0.0.0', directory: options.directory ?? plugins.path.join(process.cwd(), '.nogit/bucketsDir'), cleanSlate: options.cleanSlate ?? false, silent: options.silent ?? false, }; // Store config for middleware and feature configuration // If no config provided, create minimal default (for backward compatibility) this.config = options.config ?? { server: { port: this.options.port, address: this.options.address, silent: this.options.silent, }, storage: { directory: this.options.directory, cleanSlate: this.options.cleanSlate, }, auth: { enabled: false, credentials: [{ accessKeyId: 'S3RVER', secretAccessKey: 'S3RVER' }], }, cors: { enabled: false, allowedOrigins: ['*'], allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'], allowedHeaders: ['*'], exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'], maxAge: 86400, allowCredentials: false, }, logging: { level: 'info', format: 'text', enabled: true, }, limits: { maxObjectSize: 5 * 1024 * 1024 * 1024, maxMetadataSize: 2048, requestTimeout: 300000, }, }; this.logger = new Logger(this.config.logging); this.store = new FilesystemStore(this.options.directory); this.multipart = new MultipartUploadManager(this.options.directory); this.router = new S3Router(); this.middlewares = new MiddlewareStack(); this.setupMiddlewares(); this.setupRoutes(); } /** * Setup middleware stack */ private setupMiddlewares(): void { // CORS middleware (must be first to handle preflight requests) if (this.config.cors.enabled) { this.middlewares.use(async (req, res, ctx, next) => { const origin = req.headers.origin || req.headers.referer; // Check if origin is allowed const allowedOrigins = this.config.cors.allowedOrigins || ['*']; const isOriginAllowed = allowedOrigins.includes('*') || (origin && allowedOrigins.includes(origin)); if (isOriginAllowed) { // Set CORS headers res.setHeader( 'Access-Control-Allow-Origin', allowedOrigins.includes('*') ? '*' : origin || '*' ); if (this.config.cors.allowCredentials) { res.setHeader('Access-Control-Allow-Credentials', 'true'); } // Handle preflight OPTIONS request if (req.method === 'OPTIONS') { res.setHeader( 'Access-Control-Allow-Methods', (this.config.cors.allowedMethods || []).join(', ') ); res.setHeader( 'Access-Control-Allow-Headers', (this.config.cors.allowedHeaders || []).join(', ') ); if (this.config.cors.maxAge) { res.setHeader( 'Access-Control-Max-Age', String(this.config.cors.maxAge) ); } res.writeHead(204); res.end(); return; // Don't call next() for OPTIONS } // Set exposed headers for actual requests if (this.config.cors.exposedHeaders && this.config.cors.exposedHeaders.length > 0) { res.setHeader( 'Access-Control-Expose-Headers', this.config.cors.exposedHeaders.join(', ') ); } } await next(); }); } // Authentication middleware (simple static credentials) if (this.config.auth.enabled) { this.middlewares.use(async (req, res, ctx, next) => { const authHeader = req.headers.authorization; // Extract access key from Authorization header let accessKeyId: string | undefined; if (authHeader) { // Support multiple auth formats: // 1. AWS accessKeyId:signature // 2. AWS4-HMAC-SHA256 Credential=accessKeyId/date/region/service/aws4_request, ... if (authHeader.startsWith('AWS ')) { accessKeyId = authHeader.substring(4).split(':')[0]; } else if (authHeader.startsWith('AWS4-HMAC-SHA256')) { const credentialMatch = authHeader.match(/Credential=([^/]+)\//); accessKeyId = credentialMatch ? credentialMatch[1] : undefined; } } // Check if access key is valid const isValid = this.config.auth.credentials.some( (cred) => cred.accessKeyId === accessKeyId ); if (!isValid) { ctx.throw('AccessDenied', 'Access Denied'); return; } await next(); }); } // Logger middleware if (!this.options.silent && this.config.logging.enabled) { this.middlewares.use(async (req, res, ctx, next) => { const start = Date.now(); // Log request this.logger.request(req.method || 'UNKNOWN', req.url || '/', { headers: req.headers, }); await next(); // Log response const duration = Date.now() - start; this.logger.response( req.method || 'UNKNOWN', req.url || '/', res.statusCode || 500, duration ); }); } } /** * Setup routes */ private setupRoutes(): void { // Service level (/) this.router.get('/', ServiceController.listBuckets); // Bucket level (/:bucket) this.router.put('/:bucket', BucketController.createBucket); this.router.delete('/:bucket', BucketController.deleteBucket); this.router.get('/:bucket', BucketController.listObjects); this.router.head('/:bucket', BucketController.headBucket); // Object level (/:bucket/:key*) this.router.put('/:bucket/:key*', ObjectController.putObject); this.router.post('/:bucket/:key*', ObjectController.postObject); // For multipart operations this.router.get('/:bucket/:key*', ObjectController.getObject); this.router.head('/:bucket/:key*', ObjectController.headObject); this.router.delete('/:bucket/:key*', ObjectController.deleteObject); } /** * Handle incoming HTTP request */ private async handleRequest( req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse ): Promise { const context = new S3Context(req, res, this.store, this.multipart); try { // Execute middleware stack await this.middlewares.execute(req, res, context); // Route to handler const match = this.router.match(context.method, context.url.pathname); if (match) { context.params = match.params; await match.handler(req, res, context, match.params); } else { context.throw('NoSuchKey', 'The specified resource does not exist'); } } catch (err) { await this.handleError(err, context, res); } } /** * Handle errors and send S3-compatible error responses */ private async handleError( err: any, context: S3Context, res: plugins.http.ServerResponse ): Promise { const s3Error = err instanceof S3Error ? err : S3Error.fromError(err); // Log the error this.logger.s3Error(s3Error.code, s3Error.message, s3Error.status); // Log stack trace for server errors if (s3Error.status >= 500) { this.logger.debug('Error stack trace', { stack: err.stack || err.toString(), }); } // Send error response const errorXml = s3Error.toXML(); res.writeHead(s3Error.status, { 'Content-Type': 'application/xml', 'Content-Length': Buffer.byteLength(errorXml), }); res.end(errorXml); } /** * Start the server */ public async start(): Promise { // Initialize store await this.store.initialize(); // Initialize multipart upload manager await this.multipart.initialize(); // Clean slate if requested if (this.options.cleanSlate) { await this.store.reset(); } // Create HTTP server this.httpServer = plugins.http.createServer((req, res) => { this.handleRequest(req, res).catch((err) => { this.logger.error('Fatal error in request handler', { error: err.message, stack: err.stack, }); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); } }); }); // Start listening await new Promise((resolve, reject) => { this.httpServer!.listen(this.options.port, this.options.address, (err?: Error) => { if (err) { reject(err); } else { this.logger.info(`S3 server listening on ${this.options.address}:${this.options.port}`); resolve(); } }); }); } /** * Stop the server */ public async stop(): Promise { if (!this.httpServer) { return; } await new Promise((resolve, reject) => { this.httpServer!.close((err?: Error) => { if (err) { reject(err); } else { this.logger.info('S3 server stopped'); resolve(); } }); }); this.httpServer = undefined; } /** * Get server port (useful for testing with random ports) */ public getPort(): number { if (!this.httpServer) { throw new Error('Server not started'); } const address = this.httpServer.address(); if (typeof address === 'string') { throw new Error('Unix socket not supported'); } return address?.port || this.options.port; } /** * Get S3 descriptor for client configuration */ public getS3Descriptor(): { accessKey: string; accessSecret: string; endpoint: string; port: number; useSsl: boolean; } { return { accessKey: 'S3RVER', accessSecret: 'S3RVER', endpoint: this.options.address === '0.0.0.0' ? '127.0.0.1' : this.options.address, port: this.getPort(), useSsl: false, }; } }