336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
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 `<?xml version="1.0" encoding="UTF-8"?>\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(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Owner><ID>jtlsrv</ID><DisplayName>jtlsrv</DisplayName></Owner>
|
|
<Buckets>
|
|
<Bucket>
|
|
<Name>${BUCKET}</Name>
|
|
<CreationDate>2026-01-01T00:00:00.000Z</CreationDate>
|
|
</Bucket>
|
|
</Buckets>
|
|
</ListAllMyBucketsResult>`);
|
|
}
|
|
|
|
function listBucketXml(prefix) {
|
|
const items = listObjects(prefix).map((item) => {
|
|
const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key)));
|
|
return `<Contents>
|
|
<Key>${item.key}</Key>
|
|
<LastModified>${item.mtime.toISOString()}</LastModified>
|
|
<ETag>${etag}</ETag>
|
|
<Size>${item.size}</Size>
|
|
<StorageClass>STANDARD</StorageClass>
|
|
</Contents>`;
|
|
}).join('\n');
|
|
return xml(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Name>${BUCKET}</Name>
|
|
<Prefix>${prefix}</Prefix>
|
|
<MaxKeys>1000</MaxKeys>
|
|
<IsTruncated>false</IsTruncated>
|
|
${items}
|
|
</ListBucketResult>`);
|
|
}
|
|
|
|
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(`<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Bucket>${BUCKET}</Bucket>
|
|
<Key>${key}</Key>
|
|
<UploadId>${uploadId}</UploadId>
|
|
</InitiateMultipartUploadResult>`);
|
|
}
|
|
|
|
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(`<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Location>https://${HOST}:${PORT}/${BUCKET}/${upload.key}</Location>
|
|
<Bucket>${BUCKET}</Bucket>
|
|
<Key>${upload.key}</Key>
|
|
<ETag>${etag}</ETag>
|
|
</CompleteMultipartUploadResult>`);
|
|
}
|
|
|
|
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('<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>'));
|
|
}
|
|
|
|
const route = parseRoute(req.url);
|
|
if (route.type === 'missing') {
|
|
return send(res, 404, xml('<Error><Code>NoSuchBucket</Code><Message>Not Found</Message></Error>'));
|
|
}
|
|
|
|
if (route.type === 'root' && req.method === 'GET') {
|
|
return send(res, 200, listBucketsXml());
|
|
}
|
|
|
|
if (route.type !== 'object') {
|
|
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
|
|
}
|
|
|
|
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('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
|
|
}
|
|
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('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
|
|
}
|
|
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('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
|
|
}
|
|
return send(res, 200, result);
|
|
}
|
|
|
|
if (req.method === 'DELETE' && key && query.has('uploadId')) {
|
|
if (!abortMultipart(query.get('uploadId'))) {
|
|
return send(res, 404, xml('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
|
|
}
|
|
return send(res, 204, '');
|
|
}
|
|
|
|
return send(res, 405, xml('<Error><Code>MethodNotAllowed</Code><Message>Not allowed</Message></Error>'));
|
|
}
|
|
|
|
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('<Error><Code>InternalError</Code><Message>Server error</Message></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);
|
|
});
|
|
}
|