import crypto from 'node:crypto';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { URL } from 'node:url';
import {
ACCESS_KEY,
BIND,
BUCKET,
DATA_DIR,
HOST,
PORT,
REGION,
SECRET_KEY,
SERVER_CERT,
SERVER_KEY,
TMP_DIR,
} from './config.mjs';
import { ensureCerts } from './ensure-certs.mjs';
import { etagFor, verifyRequest } from './sigv4.mjs';
const uploads = new Map();
function xml(body) {
return `\n${body}`;
}
function send(res, status, body = '', headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/xml', ...headers });
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function objectPath(key) {
return path.join(DATA_DIR, key);
}
function ensureObjectDir(key) {
fs.mkdirSync(path.dirname(objectPath(key)), { recursive: true });
}
function listObjects(prefix = '') {
if (!fs.existsSync(DATA_DIR)) return [];
const out = [];
const walk = (dir, rel = '') => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, relPath);
} else if (!prefix || relPath.startsWith(prefix)) {
const stat = fs.statSync(full);
out.push({ key: relPath, size: stat.size, mtime: stat.mtime });
}
}
};
walk(DATA_DIR);
return out;
}
function parseRoute(urlString) {
const url = new URL(urlString, 'https://local');
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length === 0) {
return { type: 'root' };
}
if (parts[0] !== BUCKET) {
return { type: 'missing' };
}
const key = parts.slice(1).join('/');
return { type: 'object', key, query: url.searchParams };
}
function authOk(req, body) {
if (!req.headers.authorization) return false;
return verifyRequest(req, body, {
accessKey: ACCESS_KEY,
secretKey: SECRET_KEY,
region: REGION,
});
}
function listBucketsXml() {
return xml(`
jtlsrvjtlsrv
${BUCKET}
2026-01-01T00:00:00.000Z
`);
}
function listBucketXml(prefix) {
const items = listObjects(prefix).map((item) => {
const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key)));
return `
${item.key}
${item.mtime.toISOString()}
${etag}
${item.size}
STANDARD
`;
}).join('\n');
return xml(`
${BUCKET}
${prefix}
1000
false
${items}
`);
}
function initiateMultipart(key) {
const uploadId = crypto.randomUUID();
const dir = path.join(TMP_DIR, uploadId);
fs.mkdirSync(dir, { recursive: true });
uploads.set(uploadId, { key, dir, parts: new Map() });
return xml(`
${BUCKET}
${key}
${uploadId}
`);
}
function savePart(uploadId, partNumber, body) {
const upload = uploads.get(uploadId);
if (!upload) return null;
const partPath = path.join(upload.dir, String(partNumber).padStart(5, '0'));
fs.writeFileSync(partPath, body);
upload.parts.set(partNumber, partPath);
return etagFor(body);
}
function completeMultipart(uploadId) {
const upload = uploads.get(uploadId);
if (!upload) return null;
ensureObjectDir(upload.key);
const partNumbers = [...upload.parts.keys()].sort((a, b) => a - b);
const chunks = partNumbers.map((n) => fs.readFileSync(upload.parts.get(n)));
const finalData = Buffer.concat(chunks);
fs.writeFileSync(objectPath(upload.key), finalData);
fs.rmSync(upload.dir, { recursive: true, force: true });
uploads.delete(uploadId);
const etag = etagFor(finalData);
return xml(`
https://${HOST}:${PORT}/${BUCKET}/${upload.key}
${BUCKET}
${upload.key}
${etag}
`);
}
function abortMultipart(uploadId) {
const upload = uploads.get(uploadId);
if (!upload) return false;
fs.rmSync(upload.dir, { recursive: true, force: true });
uploads.delete(uploadId);
return true;
}
async function handle(req, res) {
const body = await readBody(req);
if (!authOk(req, body)) {
console.error(`${req.method} ${req.url} -> 403 auth failed`);
return send(res, 403, xml('AccessDeniedAccess Denied'));
}
const route = parseRoute(req.url);
if (route.type === 'missing') {
return send(res, 404, xml('NoSuchBucketNot Found'));
}
if (route.type === 'root' && req.method === 'GET') {
return send(res, 200, listBucketsXml());
}
if (route.type !== 'object') {
return send(res, 404, xml('NoSuchKeyNot Found'));
}
const { key, query } = route;
if (req.method === 'GET' && !key) {
return send(res, 200, listBucketXml(query.get('prefix') || ''));
}
if (req.method === 'GET' && key) {
const file = objectPath(key);
if (!fs.existsSync(file)) {
return send(res, 404, xml('NoSuchKeyNot Found'));
}
const data = fs.readFileSync(file);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': data.length,
ETag: etagFor(data),
});
return res.end(data);
}
if (req.method === 'HEAD' && key) {
const file = objectPath(key);
if (!fs.existsSync(file)) {
return send(res, 404, '');
}
const stat = fs.statSync(file);
return send(res, 200, '', {
'Content-Length': stat.size,
ETag: etagFor(fs.readFileSync(file)),
});
}
if (req.method === 'PUT' && key && query.has('uploadId') && query.has('partNumber')) {
const etag = savePart(query.get('uploadId'), Number(query.get('partNumber')), body);
if (!etag) {
return send(res, 404, xml('NoSuchUploadNot Found'));
}
return send(res, 200, '', { ETag: etag });
}
if (req.method === 'PUT' && key) {
ensureObjectDir(key);
fs.writeFileSync(objectPath(key), body);
return send(res, 200, '', { ETag: etagFor(body) });
}
if (req.method === 'POST' && key && query.has('uploads')) {
return send(res, 200, initiateMultipart(key));
}
if (req.method === 'POST' && key && query.has('uploadId')) {
const result = completeMultipart(query.get('uploadId'));
if (!result) {
return send(res, 404, xml('NoSuchUploadNot Found'));
}
return send(res, 200, result);
}
if (req.method === 'DELETE' && key && query.has('uploadId')) {
if (!abortMultipart(query.get('uploadId'))) {
return send(res, 404, xml('NoSuchUploadNot Found'));
}
return send(res, 204, '');
}
return send(res, 405, xml('MethodNotAllowedNot allowed'));
}
export function startServer() {
ensureCerts(HOST);
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(TMP_DIR, { recursive: true });
const server = https.createServer(
{ key: fs.readFileSync(SERVER_KEY), cert: fs.readFileSync(SERVER_CERT) },
(req, res) => {
handle(req, res).catch((err) => {
console.error(err);
send(res, 500, xml('InternalErrorServer error'));
});
}
);
return new Promise((resolve) => {
server.listen(PORT, BIND, () => {
console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`);
resolve(server);
});
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
startServer();
}