2025-11-21 14:32:19 +00:00
|
|
|
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';
|
2025-11-23 22:37:32 +00:00
|
|
|
import { Logger } from './logger.js';
|
2025-11-23 22:41:46 +00:00
|
|
|
import { MultipartUploadManager } from './multipart-manager.js';
|
2025-11-21 14:32:19 +00:00
|
|
|
import { ServiceController } from '../controllers/service.controller.js';
|
|
|
|
|
import { BucketController } from '../controllers/bucket.controller.js';
|
|
|
|
|
import { ObjectController } from '../controllers/object.controller.js';
|
2025-11-23 22:37:32 +00:00
|
|
|
import type { ISmarts3Config } from '../index.js';
|
2025-11-21 14:32:19 +00:00
|
|
|
|
|
|
|
|
export interface ISmarts3ServerOptions {
|
|
|
|
|
port?: number;
|
|
|
|
|
address?: string;
|
|
|
|
|
directory?: string;
|
|
|
|
|
cleanSlate?: boolean;
|
|
|
|
|
silent?: boolean;
|
2025-11-23 22:37:32 +00:00
|
|
|
config?: Required<ISmarts3Config>;
|
2025-11-21 14:32:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
2025-11-23 22:31:44 +00:00
|
|
|
public store: FilesystemStore; // Made public for direct access from Smarts3 class
|
2025-11-23 22:41:46 +00:00
|
|
|
public multipart: MultipartUploadManager; // Made public for controller access
|
2025-11-23 22:37:32 +00:00
|
|
|
private options: Required<Omit<ISmarts3ServerOptions, 'config'>>;
|
|
|
|
|
private config: Required<ISmarts3Config>;
|
|
|
|
|
private logger: Logger;
|
2025-11-21 14:32:19 +00:00
|
|
|
|
|
|
|
|
constructor(options: ISmarts3ServerOptions = {}) {
|
|
|
|
|
this.options = {
|
2025-11-23 22:37:32 +00:00
|
|
|
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,
|
2025-11-21 14:32:19 +00:00
|
|
|
};
|
|
|
|
|
|
2025-11-23 22:37:32 +00:00
|
|
|
// 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);
|
2025-11-21 14:32:19 +00:00
|
|
|
this.store = new FilesystemStore(this.options.directory);
|
2025-11-23 22:41:46 +00:00
|
|
|
this.multipart = new MultipartUploadManager(this.options.directory);
|
2025-11-21 14:32:19 +00:00
|
|
|
this.router = new S3Router();
|
|
|
|
|
this.middlewares = new MiddlewareStack();
|
|
|
|
|
|
|
|
|
|
this.setupMiddlewares();
|
|
|
|
|
this.setupRoutes();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup middleware stack
|
|
|
|
|
*/
|
|
|
|
|
private setupMiddlewares(): void {
|
2025-11-23 22:37:32 +00:00
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 14:32:19 +00:00
|
|
|
// Logger middleware
|
2025-11-23 22:37:32 +00:00
|
|
|
if (!this.options.silent && this.config.logging.enabled) {
|
2025-11-21 14:32:19 +00:00
|
|
|
this.middlewares.use(async (req, res, ctx, next) => {
|
|
|
|
|
const start = Date.now();
|
2025-11-23 22:37:32 +00:00
|
|
|
|
|
|
|
|
// Log request
|
|
|
|
|
this.logger.request(req.method || 'UNKNOWN', req.url || '/', {
|
|
|
|
|
headers: req.headers,
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-21 14:32:19 +00:00
|
|
|
await next();
|
2025-11-23 22:37:32 +00:00
|
|
|
|
|
|
|
|
// Log response
|
2025-11-21 14:32:19 +00:00
|
|
|
const duration = Date.now() - start;
|
2025-11-23 22:37:32 +00:00
|
|
|
this.logger.response(
|
|
|
|
|
req.method || 'UNKNOWN',
|
|
|
|
|
req.url || '/',
|
|
|
|
|
res.statusCode || 500,
|
|
|
|
|
duration
|
|
|
|
|
);
|
2025-11-21 14:32:19 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2025-11-23 22:41:46 +00:00
|
|
|
this.router.post('/:bucket/:key*', ObjectController.postObject); // For multipart operations
|
2025-11-21 14:32:19 +00:00
|
|
|
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<void> {
|
2025-11-23 22:41:46 +00:00
|
|
|
const context = new S3Context(req, res, this.store, this.multipart);
|
2025-11-21 14:32:19 +00:00
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
const s3Error = err instanceof S3Error ? err : S3Error.fromError(err);
|
|
|
|
|
|
2025-11-23 22:37:32 +00:00
|
|
|
// 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(),
|
|
|
|
|
});
|
2025-11-21 14:32:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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<void> {
|
|
|
|
|
// Initialize store
|
|
|
|
|
await this.store.initialize();
|
|
|
|
|
|
2025-11-23 22:41:46 +00:00
|
|
|
// Initialize multipart upload manager
|
|
|
|
|
await this.multipart.initialize();
|
|
|
|
|
|
2025-11-21 14:32:19 +00:00
|
|
|
// 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) => {
|
2025-11-23 22:37:32 +00:00
|
|
|
this.logger.error('Fatal error in request handler', {
|
|
|
|
|
error: err.message,
|
|
|
|
|
stack: err.stack,
|
|
|
|
|
});
|
2025-11-21 14:32:19 +00:00
|
|
|
if (!res.headersSent) {
|
|
|
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
|
|
|
res.end('Internal Server Error');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Start listening
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
this.httpServer!.listen(this.options.port, this.options.address, (err?: Error) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
reject(err);
|
|
|
|
|
} else {
|
2025-11-23 22:37:32 +00:00
|
|
|
this.logger.info(`S3 server listening on ${this.options.address}:${this.options.port}`);
|
2025-11-21 14:32:19 +00:00
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop the server
|
|
|
|
|
*/
|
|
|
|
|
public async stop(): Promise<void> {
|
|
|
|
|
if (!this.httpServer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
this.httpServer!.close((err?: Error) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
reject(err);
|
|
|
|
|
} else {
|
2025-11-23 22:37:32 +00:00
|
|
|
this.logger.info('S3 server stopped');
|
2025-11-21 14:32:19 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|