Files
shittyserverless/index.js

319 lines
10 KiB
JavaScript
Raw Normal View History

2025-09-13 20:53:31 +02:00
const express = require('express')
const { spawn } = require('child_process');
const readline = require('readline');
const AWS = require('aws-sdk');
const config = require('./config.js');
const fs = require('fs');
const path = require('path');
const { get } = require('http');
const app = express();
const port = 3000;
// FILE FUNCS
// Configure S3 client if not using local
let s3;
if (config.buckregion !== 'local') { // assuming 'local' means local file system
s3 = new AWS.S3({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
endpoint: config.endpoint,
s3ForcePathStyle: true, // needed for self-hosted S3 like MinIO or garage
});
} else {
console.log("!! Using local storage for files. This is not recommended for production.")
}
function getFileFromStorage(loc) {
return new Promise((resolve, reject) => {
if (config.buckregion === 'local') {
fs.readFile(path.join("localstorage", loc), { encoding: 'utf-8' }, (err, data) => {
if (err) return reject(err);
resolve(data);
});
} else {
// S3 access
const params = {
Bucket: config.bucketname,
Key: loc, // loc is the file key in S3
};
s3.getObject(params, (err, data) => {
if (err) return reject(err);
resolve(data.Body.toString('utf-8'));
});
}
});
}
function writeLocalFile(loc, data) {
return new Promise((resolve, reject) => {
fs.writeFile(loc, data, (err) => {
if (err) return reject(err);
resolve();
});
});
}
app.get('/', (req, res) => {
res.send(`<h1>ShittyServerless execution instance.</h1> If you are an end-user, contact site-admin for documentation.<br>
If you are a developer, deploy your serverless functions by placing files in the garage webui manually.`);
})
const exitCodeLookup = {
0: 200,
1: 500,
2: 403,
3: 401,
4: 404,
} // Lookup table to map exit codes to HTTP status codes
function execFileAsync(req,res, command, location, opts = {}) {
// opts: { timeoutMs: number } optional
const timeoutMs = opts.timeoutMs || Infinity; // default no timeout
let killTimer; // to hold timeout timer
return new Promise((resolve, reject) => {
let finished = false;
function onceFinish(err) {
if (finished) return;
finished = true;
try{clearTimeout(killTimer);console.log("Cleared timer")}catch(e){}
if (err) {
try { res.status(500).end(); } catch (e) {}
reject(err);
} else {
resolve();
}
}
(async () => {
try {
// --- prepare temp file (keeps your original logic) ---
const tempDir = '/tmp';
const fileName = path.basename(location[0]);
const tempFilePath = path.join(tempDir, fileName);
const contents = await getFileFromStorage(location[0]);
await writeLocalFile(tempFilePath, contents);
// --- spawn process ---
const child = spawn(command, [tempFilePath]);
// make sure we DONT close stdin (we need to send data to the child for it to have reactiveness)
child.on('error', (err) => {
console.error('child process error', err);
onceFinish(new Error('Failed to spawn child process: ' + err.message));
});
// safety timeout to avoid indefinite hangs
killTimer = setTimeout(() => {
if (finished) return;
console.error(`child timeout after ${timeoutMs}ms — killing pid ${child.pid}`);
try { child.kill('SIGKILL'); } catch (e) {}
onceFinish(new Error(`Child process timeout after ${timeoutMs}ms`));
}, timeoutMs);
// --- streaming header parser ---
let isReadingHeaders = true;
let headerBuf = ''; // string buffer for header parsing
let headers = { 'Content-Type': 'text/plain' };
let statusCode = 200;
function parseHeadersAndFlush(remainder) {
const lines = headerBuf.split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) continue;
// if line looks like "Key: value"
const idx = line.indexOf(':');
if (idx > -1) {
const key = line.slice(0, idx).trim();
const val = line.slice(idx + 1).trim();
// treat status header specially if you want
if (key.toLowerCase() === 'status') {
const parsed = parseInt(val, 10);
if (!Number.isNaN(parsed)) statusCode = parsed;
} else {
headers[key] = val;
}
}
}
// Send headers and write remainder
try {
res.writeHead(statusCode, headers);
} catch (e) {
console.warn('res.writeHead failed', e);
}
if (remainder && remainder.length) {
res.write(remainder);
}
}
// We will listen on stdout data and look for the token.
child.stdout.on('data', (chunk) => {
if (finished) return;
const str = chunk.toString('utf8');
if (isReadingHeaders) {
headerBuf += str;
// Look for the token on its own line anywhere in headerBuf
const token = '\n!!STARTBODY';
let index = headerBuf.indexOf(token);
// also allow token at very start (no preceding newline)
if (index === -1 && headerBuf.indexOf('!!STARTBODY') === 0) {
index = 0;
}
if (headerBuf.endsWith("!!RECVDATA")) {
console.log("Child is requesting data from us...");
child.stdin.write(JSON.stringify({
method: req.method,
headers: req.headers,
query: req.query,
params: req.params
}));
child.stdin.write("\n"); // newline to signal end of JSON, because child most likely uses input() or readline()
headerBuf = headerBuf.slice(0, headerBuf.length - "!!RECVDATA".length); // remove the token, to avoid cluttering headerspace
return; // wait for more data
}
if (index !== -1) {
// header portion is everything before token (strip a preceding newline)
let headerPart = headerBuf.slice(0, index);
// remainder after token
let remainder = headerBuf.slice(index + token.length);
// If token was at start without the leading \n, we've included token; handle that case
if (headerPart.startsWith('\n')) headerPart = headerPart.slice(1);
// parse headerPart lines
headerBuf = headerPart;
parseHeadersAndFlush(remainder);
// switch mode
isReadingHeaders = false;
} else {
// if no token yet, keep buffering — but to avoid unbounded growth, cap buffer size
const MAX_HEADER_BUF = 64 * 1024; // 64KB
if (headerBuf.length > MAX_HEADER_BUF) {
// give up and treat whole buffer as headers
parseHeadersAndFlush('');
isReadingHeaders = false;
}
}
} else {
// already reading body — stream straight to response
res.write(str);
}
});
// also forward stderr to console (and optionally to response)
child.stderr.on('data', (data) => {
console.error(`stderr: ${data.toString()}`);
});
child.on('close', (code) => {
// If we never left header mode, send a helpful message
if (isReadingHeaders) {
try {
res.writeHead(200, headers);
res.write("No body content. Double check that the child process emits the delimiter `!!STARTBODY` followed by a newline.\n");
} catch (e) {
console.warn('writeHead on close failed', e);
}
}
console.log(`child process exited with code ${code}`);
const mappedStatus = exitCodeLookup[code] || 500;
console.log(`Mapping exit code ${code} to HTTP status ${mappedStatus}`);
try { res.status(mappedStatus);
res.end(); } catch (e) {console.log(e)}
// Remove temp file
try {
fs.unlink(tempFilePath, (err) => {
if (err) {
console.warn('Failed to remove temp file', tempFilePath, err);
} else {
console.log('Temp file removed', tempFilePath);
}
});
} catch (e) {
console.warn('unlink failed', e);
}
onceFinish(null,code);
});
} catch (err) {
onceFinish(err);
}
})();
});
}
const lookup = {
sh: 'bash',
py: 'python3',
js: 'node',
}
app.get('/*path', async (req, res) => {
const filePath = req.path.replace(/^\/+/, ''); // remove leading slash(s)
console.log(`[request] filePath="${filePath}"`);
if (!filePath) {
return res.status(400).send('No file requested');
}
// default timeout (ms) for execFileAsync; tune as needed
const execOpts = { timeoutMs: 30_000 };
try {
if (filePath.includes('.')) {
const ext = filePath.split('.').pop();
console.log(`[lookup] requested ext="${ext}"`);
if (lookup[ext]) {
await execFileAsync(req,res, lookup[ext], [filePath], execOpts);
return; // response handled by execFileAsync
} else {
console.log(`[lookup] no handler for extension "${ext}"`);
}
}
// Brute-force check (ensure fs checks the correct cwd or use absolute paths)
let found = false;
for (const ext in lookup) {
const candidate = filePath + '.' + ext;
console.log(`[bruteforce] checking ${candidate}`);
try{
await getFileFromStorage(candidate);
console.log(`[bruteforce] found ${candidate} -> handler ${lookup[ext]}`);
await execFileAsync(req,res, lookup[ext], [candidate], execOpts);
found = true;
break;
} catch {continue;}
}
if (!found) {
if (!res.headersSent) {
res.status(404).send("No matching file found for serverless execution");
} else {
res.end();
}
return;
}
} catch (err) {
console.error('[execFileAsync] error:', err && err.stack ? err.stack : err);
// If execFileAsync already sent headers/body, don't try to send another response
if (!res.headersSent) {
res.status(500).send("Serverless function failed to execute");
} else {
try { res.end(); } catch (e) {}
}
return;
}
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})