import crypto from 'node:crypto';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { URL, pathToFileURL } 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) {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'DELETE') {
return Promise.resolve(Buffer.alloc(0));
}
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;
}
function debug(...args) {
if (process.env.S3_BACKUP_DEBUG) {
console.error(...args);
}
}
async function handle(req, res) {
const body = await readBody(req);
debug(`${req.method} ${req.url} len=${body.length}`);
if (!authOk(req, body)) {
debug(`auth failed ${req.method} ${req.url}`);
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 === 'HEAD' && !key) {
return send(res, 200, '');
}
if (req.method === 'GET' && key) {
const file = objectPath(key);
if (!fs.existsSync(file)) {
return send(res, 404, xml('NoSuchKeyNot Found'));
}
const stat = fs.statSync(file);
const etag = etagFor(fs.readFileSync(file));
const range = req.headers.range;
if (range) {
const match = /^bytes=(\d+)-(\d*)$/i.exec(range);
if (match) {
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : stat.size - 1;
if (start >= stat.size || end < start) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
return res.end();
}
const length = end - start + 1;
res.writeHead(206, {
'Content-Type': 'application/octet-stream',
'Content-Length': length,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
ETag: etag,
});
return fs.createReadStream(file, { start, end }).pipe(res);
}
}
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
ETag: etag,
});
return fs.createReadStream(file).pipe(res);
}
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,
'Accept-Ranges': 'bytes',
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() {
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, reject) => {
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${PORT} is already in use`));
return;
}
reject(err);
});
server.on('secureConnection', (tlsSocket) => {
debug(`tls ${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`);
});
server.listen(PORT, BIND, () => {
console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`);
resolve(server);
});
});
}
if (process.env.S3_BACKUP_CHILD || import.meta.url === pathToFileURL(process.argv[1]).href) {
startServer().catch((err) => {
console.error(err.message || err);
process.exit(1);
});
}