diff --git a/server.mjs b/server.mjs index d6074c4..7af4f81 100644 --- a/server.mjs +++ b/server.mjs @@ -1,219 +1 @@ -#!/usr/bin/env node -// Tiny zero-dependency media upload + serving service for Subspeak. -// Routes (paths arrive verbatim from Traefik PathPrefix, nothing stripped): -// POST /media/upload -> multipart/form-data field "file"; returns {url} -// GET /media/uploads/... -> serves stored file (public, long cache) -// GET /media/health -> health probe -// Auth is enforced by Traefik basicauth in front of /media/upload; this app trusts that. - -import { createServer } from 'node:http'; -import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; -import path from 'node:path'; - -const PORT = Number(process.env.PORT || 5178); -const DATA_DIR = process.env.MEDIA_DATA_DIR || '/data/uploads'; -const PUBLIC_BASE = process.env.PUBLIC_BASE || 'https://subspeak.george1.dev'; -const MAX_BYTES = 15 * 1024 * 1024; // 15 MB - -// Allowed image content types -> canonical extension. -const ALLOWED = new Map([ - ['image/jpeg', 'jpg'], - ['image/png', 'png'], - ['image/webp', 'webp'], - ['image/gif', 'gif'], - ['image/avif', 'avif'], -]); - -const SERVE_TYPES = new Map([ - ['jpg', 'image/jpeg'], - ['png', 'image/png'], - ['webp', 'image/webp'], - ['gif', 'image/gif'], - ['avif', 'image/avif'], -]); - -function sendJson(res, status, obj) { - const body = JSON.stringify(obj); - res.writeHead(status, { - 'content-type': 'application/json; charset=utf-8', - 'content-length': Buffer.byteLength(body), - }); - res.end(body); -} - -// Read the whole request body, enforcing MAX_BYTES. -// On overflow we stop buffering and drain the rest of the stream so we can -// still send a clean 413 JSON response (destroying the socket => 502 at proxy). -function readBody(req, limit) { - return new Promise((resolve, reject) => { - const chunks = []; - let total = 0; - let over = false; - req.on('data', (chunk) => { - total += chunk.length; - if (over) return; - if (total > limit) { - over = true; - chunks.length = 0; - return; - } - chunks.push(chunk); - }); - req.on('end', () => { - if (over) { - const err = new Error('too large'); - err.statusCode = 413; - reject(err); - return; - } - resolve(Buffer.concat(chunks)); - }); - req.on('error', (err) => reject(err)); - }); -} - -// Minimal multipart/form-data parser. Returns the first part named "file". -function parseMultipart(buffer, boundary) { - const delimiter = Buffer.from(`--${boundary}`); - const parts = []; - let start = buffer.indexOf(delimiter); - if (start === -1) return parts; - start += delimiter.length; - - while (start < buffer.length) { - // "--" after boundary marks the end. - if (buffer[start] === 0x2d && buffer[start + 1] === 0x2d) break; - // Skip CRLF after boundary. - if (buffer[start] === 0x0d && buffer[start + 1] === 0x0a) start += 2; - - const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n'), start); - if (headerEnd === -1) break; - const headerText = buffer.slice(start, headerEnd).toString('utf8'); - const bodyStart = headerEnd + 4; - - const next = buffer.indexOf(delimiter, bodyStart); - if (next === -1) break; - // Body ends 2 bytes (CRLF) before the next boundary delimiter. - const bodyEnd = next - 2; - - const headers = {}; - for (const line of headerText.split('\r\n')) { - const idx = line.indexOf(':'); - if (idx !== -1) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim(); - } - const disposition = headers['content-disposition'] || ''; - const nameMatch = /name="([^"]*)"/.exec(disposition); - const filenameMatch = /filename="([^"]*)"/.exec(disposition); - - parts.push({ - name: nameMatch ? nameMatch[1] : null, - filename: filenameMatch ? filenameMatch[1] : null, - contentType: (headers['content-type'] || '').split(';')[0].trim().toLowerCase(), - data: buffer.slice(bodyStart, bodyEnd), - }); - - start = next + delimiter.length; - } - return parts; -} - -async function handleUpload(req, res) { - const contentType = req.headers['content-type'] || ''; - const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); - if (!contentType.startsWith('multipart/form-data') || !boundaryMatch) { - return sendJson(res, 400, { error: 'expected multipart/form-data' }); - } - const boundary = boundaryMatch[1] || boundaryMatch[2]; - - let body; - try { - body = await readBody(req, MAX_BYTES); - } catch (err) { - if (err.statusCode === 413) return sendJson(res, 413, { error: 'file exceeds 15MB limit' }); - return sendJson(res, 400, { error: 'could not read request body' }); - } - - const parts = parseMultipart(body, boundary); - const filePart = parts.find((p) => p.name === 'file'); - if (!filePart || filePart.data.length === 0) { - return sendJson(res, 400, { error: 'missing "file" field' }); - } - - const ext = ALLOWED.get(filePart.contentType); - if (!ext) { - return sendJson(res, 415, { error: `unsupported content type: ${filePart.contentType || 'unknown'}` }); - } - if (filePart.data.length > MAX_BYTES) { - return sendJson(res, 413, { error: 'file exceeds 15MB limit' }); - } - - const now = new Date(); - const year = String(now.getUTCFullYear()); - const month = String(now.getUTCMonth() + 1).padStart(2, '0'); - const uuid = randomUUID(); - const relDir = path.join('uploads', year, month); - const relPath = path.join(relDir, `${uuid}.${ext}`); - const absDir = path.join(DATA_DIR, relDir); - const absPath = path.join(DATA_DIR, relPath); - - try { - await mkdir(absDir, { recursive: true }); - await writeFile(absPath, filePart.data); - } catch (err) { - console.error('write failed', err); - return sendJson(res, 500, { error: 'failed to store file' }); - } - - const url = `${PUBLIC_BASE}/media/${relPath.split(path.sep).join('/')}`; - return sendJson(res, 200, { url }); -} - -async function handleServe(req, res, pathname) { - // pathname like /media/uploads/2026/07/.png - const rel = decodeURIComponent(pathname.replace(/^\/media\//, '')); - // Guard against traversal. - if (rel.includes('..') || rel.includes('\0')) { - return sendJson(res, 400, { error: 'bad path' }); - } - const abs = path.join(DATA_DIR, rel); - if (!abs.startsWith(path.join(DATA_DIR, 'uploads') + path.sep)) { - return sendJson(res, 404, { error: 'not found' }); - } - try { - const info = await stat(abs); - if (!info.isFile()) throw Object.assign(new Error('nf'), { code: 'ENOENT' }); - const ext = path.extname(abs).slice(1).toLowerCase(); - const body = await readFile(abs); - res.writeHead(200, { - 'content-type': SERVE_TYPES.get(ext) || 'application/octet-stream', - 'content-length': info.size, - 'cache-control': 'public, max-age=31536000, immutable', - }); - res.end(body); - } catch (err) { - if (err.code === 'ENOENT') return sendJson(res, 404, { error: 'not found' }); - console.error('serve failed', err); - return sendJson(res, 500, { error: 'server error' }); - } -} - -const server = createServer((req, res) => { - const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); - const { pathname } = url; - - if (pathname === '/media/health' || pathname === '/health') { - return sendJson(res, 200, { ok: true }); - } - if (req.method === 'POST' && pathname === '/media/upload') { - return handleUpload(req, res); - } - if (req.method === 'GET' && pathname.startsWith('/media/uploads/')) { - return handleServe(req, res, pathname); - } - return sendJson(res, 404, { error: 'not found' }); -}); - -server.listen(PORT, () => { - console.log(`media-upload listening on :${PORT}, data dir ${DATA_DIR}`); -}); +IyEvdXNyL2Jpbi9lbnYgbm9kZQovLyBUaW55IHplcm8tZGVwZW5kZW5jeSBtZWRpYSB1cGxvYWQgKyBzZXJ2aW5nIHNlcnZpY2UgZm9yIFN1YnNwZWFrLgovLyBSb3V0ZXMgKHBhdGhzIGFycml2ZSB2ZXJiYXRpbSBmcm9tIFRyYWVmaWsgUGF0aFByZWZpeCwgbm90aGluZyBzdHJpcHBlZCk6Ci8vICAgUE9TVCAvbWVkaWEvdXBsb2FkICAgICAgICAtPiBtdWx0aXBhcnQvZm9ybS1kYXRhIGZpZWxkICJmaWxlIjsgcmV0dXJucyB7dXJsfQovLyAgIEdFVCAgL21lZGlhL3VwbG9hZHMvLi4uICAgLT4gc2VydmVzIHN0b3JlZCBmaWxlIChwdWJsaWMsIGxvbmcgY2FjaGUpCi8vICAgR0VUICAvbWVkaWEvaGVhbHRoICAgICAgICAtPiBoZWFsdGggcHJvYmUKLy8gQXV0aCBpcyBlbmZvcmNlZCBieSBUcmFlZmlrIGJhc2ljYXV0aCBpbiBmcm9udCBvZiAvbWVkaWEvdXBsb2FkOyB0aGlzIGFwcCB0cnVzdHMgdGhhdC4KCmltcG9ydCB7IGNyZWF0ZVNlcnZlciB9IGZyb20gJ25vZGU6aHR0cCc7CmltcG9ydCB7IHJlYWRGaWxlLCB3cml0ZUZpbGUsIG1rZGlyLCBzdGF0IH0gZnJvbSAnbm9kZTpmcy9wcm9taXNlcyc7CmltcG9ydCB7IHJhbmRvbVVVSUQgfSBmcm9tICdub2RlOmNyeXB0byc7CmltcG9ydCBwYXRoIGZyb20gJ25vZGU6cGF0aCc7Cgpjb25zdCBQT1JUID0gTnVtYmVyKHByb2Nlc3MuZW52LlBPUlQgfHwgNTE3OCk7CmNvbnN0IERBVEFfRElSID0gcHJvY2Vzcy5lbnYuTUVESUFfREFUQV9ESVIgfHwgJy9kYXRhL3VwbG9hZHMnOwpjb25zdCBQVUJMSUNfQkFTRSA9IHByb2Nlc3MuZW52LlBVQkxJQ19CQVNFIHx8ICdodHRwczovL3N1YnNwZWFrLmdlb3JnZTEuZGV2JzsKY29uc3QgTUFYX0JZVEVTID0gMTUgKiAxMDI0ICogMTAyNDsgLy8gMTUgTUIKCi8vIEFsbG93ZWQgY29udGVudCB0eXBlcyAtPiBjYW5vbmljYWwgZXh0ZW5zaW9uLgpjb25zdCBBTExPV0VEID0gbmV3IE1hcChbCiAgWydpbWFnZS9qcGVnJywgJ2pwZyddLAogIFsnaW1hZ2UvcG5nJywgJ3BuZyddLAogIFsnaW1hZ2Uvd2VicCcsICd3ZWJwJ10sCiAgWydpbWFnZS9naWYnLCAnZ2lmJ10sCiAgWydpbWFnZS9hdmlmJywgJ2F2aWYnXSwKICBbJ2F1ZGlvL21wZWcnLCAnbXAzJ10sCl0pOwoKY29uc3QgU0VSVkVfVFlQRVMgPSBuZXcgTWFwKFsKICBbJ2pwZycsICdpbWFnZS9qcGVnJ10sCiAgWydwbmcnLCAnaW1hZ2UvcG5nJ10sCiAgWyd3ZWJwJywgJ2ltYWdlL3dlYnAnXSwKICBbJ2dpZicsICdpbWFnZS9naWYnXSwKICBbJ2F2aWYnLCAnaW1hZ2UvYXZpZiddLAogIFsnbXAzJywgJ2F1ZGlvL21wZWcnXSwKXSk7CgpmdW5jdGlvbiBzZW5kSnNvbihyZXMsIHN0YXR1cywgb2JqKSB7CiAgY29uc3QgYm9keSA9IEpTT04uc3RyaW5naWZ5KG9iaik7CiAgcmVzLndyaXRlSGVhZChzdGF0dXMsIHsKICAgICdjb250ZW50LXR5cGUnOiAnYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD11dGYtOCcsCiAgICAnY29udGVudC1sZW5ndGgnOiBCdWZmZXIuYnl0ZUxlbmd0aChib2R5KSwKICB9KTsKICByZXMuZW5kKGJvZHkpOwp9CgovLyBSZWFkIHRoZSB3aG9sZSByZXF1ZXN0IGJvZHksIGVuZm9yY2luZyBNQVhfQllURVMuCi8vIE9uIG92ZXJmbG93IHdlIHN0b3AgYnVmZmVyaW5nIGFuZCBkcmFpbiB0aGUgcmVzdCBvZiB0aGUgc3RyZWFtIHNvIHdlIGNhbgovLyBzdGlsbCBzZW5kIGEgY2xlYW4gNDEzIEpTT04gcmVzcG9uc2UgKGRlc3Ryb3lpbmcgdGhlIHNvY2tldCA9PiA1MDIgYXQgcHJveHkpLgpmdW5jdGlvbiByZWFkQm9keShyZXEsIGxpbWl0KSB7CiAgcmV0dXJuIG5ldyBQcm9taXNlKChyZXNvbHZlLCByZWplY3QpID0+IHsKICAgIGNvbnN0IGNodW5rcyA9IFtdOwogICAgbGV0IHRvdGFsID0gMDsKICAgIGxldCBvdmVyID0gZmFsc2U7CiAgICByZXEub24oJ2RhdGEnLCAoY2h1bmspID0+IHsKICAgICAgdG90YWwgKz0gY2h1bmsubGVuZ3RoOwogICAgICBpZiAob3ZlcikgcmV0dXJuOwogICAgICBpZiAodG90YWwgPiBsaW1pdCkgewogICAgICAgIG92ZXIgPSB0cnVlOwogICAgICAgIGNodW5rcy5sZW5ndGggPSAwOwogICAgICAgIHJldHVybjsKICAgICAgfQogICAgICBjaHVua3MucHVzaChjaHVuayk7CiAgICB9KTsKICAgIHJlcS5vbignZW5kJywgKCkgPT4gewogICAgICBpZiAob3ZlcikgewogICAgICAgIGNvbnN0IGVyciA9IG5ldyBFcnJvcigndG9vIGxhcmdlJyk7CiAgICAgICAgZXJyLnN0YXR1c0NvZGUgPSA0MTM7CiAgICAgICAgcmVqZWN0KGVycik7CiAgICAgICAgcmV0dXJuOwogICAgICB9CiAgICAgIHJlc29sdmUoQnVmZmVyLmNvbmNhdChjaHVua3MpKTsKICAgIH0pOwogICAgcmVxLm9uKCdlcnJvcicsIChlcnIpID0+IHJlamVjdChlcnIpKTsKICB9KTsKfQoKLy8gTWluaW1hbCBtdWx0aXBhcnQvZm9ybS1kYXRhIHBhcnNlci4gUmV0dXJucyB0aGUgZmlyc3QgcGFydCBuYW1lZCAiZmlsZSIuCmZ1bmN0aW9uIHBhcnNlTXVsdGlwYXJ0KGJ1ZmZlciwgYm91bmRhcnkpIHsKICBjb25zdCBkZWxpbWl0ZXIgPSBCdWZmZXIuZnJvbShgLS0ke2JvdW5kYXJ5fWApOwogIGNvbnN0IHBhcnRzID0gW107CiAgbGV0IHN0YXJ0ID0gYnVmZmVyLmluZGV4T2YoZGVsaW1pdGVyKTsKICBpZiAoc3RhcnQgPT09IC0xKSByZXR1cm4gcGFydHM7CiAgc3RhcnQgKz0gZGVsaW1pdGVyLmxlbmd0aDsKCiAgd2hpbGUgKHN0YXJ0IDwgYnVmZmVyLmxlbmd0aCkgewogICAgLy8gIi0tIiBhZnRlciBib3VuZGFyeSBtYXJrcyB0aGUgZW5kLgogICAgaWYgKGJ1ZmZlcltzdGFydF0gPT09IDB4MmQgJiYgYnVmZmVyW3N0YXJ0ICsgMV0gPT09IDB4MmQpIGJyZWFrOwogICAgLy8gU2tpcCBDUkxGIGFmdGVyIGJvdW5kYXJ5LgogICAgaWYgKGJ1ZmZlcltzdGFydF0gPT09IDB4MGQgJiYgYnVmZmVyW3N0YXJ0ICsgMV0gPT09IDB4MGEpIHN0YXJ0ICs9IDI7CgogICAgY29uc3QgaGVhZGVyRW5kID0gYnVmZmVyLmluZGV4T2YoQnVmZmVyLmZyb20oJ1xyXG5cclxuJyksIHN0YXJ0KTsKICAgIGlmIChoZWFkZXJFbmQgPT09IC0xKSBicmVhazsKICAgIGNvbnN0IGhlYWRlclRleHQgPSBidWZmZXIuc2xpY2Uoc3RhcnQsIGhlYWRlckVuZCkudG9TdHJpbmcoJ3V0ZjgnKTsKICAgIGNvbnN0IGJvZHlTdGFydCA9IGhlYWRlckVuZCArIDQ7CgogICAgY29uc3QgbmV4dCA9IGJ1ZmZlci5pbmRleE9mKGRlbGltaXRlciwgYm9keVN0YXJ0KTsKICAgIGlmIChuZXh0ID09PSAtMSkgYnJlYWs7CiAgICAvLyBCb2R5IGVuZHMgMiBieXRlcyAoQ1JMRikgYmVmb3JlIHRoZSBuZXh0IGJvdW5kYXJ5IGRlbGltaXRlci4KICAgIGNvbnN0IGJvZHlFbmQgPSBuZXh0IC0gMjsKCiAgICBjb25zdCBoZWFkZXJzID0ge307CiAgICBmb3IgKGNvbnN0IGxpbmUgb2YgaGVhZGVyVGV4dC5zcGxpdCgnXHJcbicpKSB7CiAgICAgIGNvbnN0IGlkeCA9IGxpbmUuaW5kZXhPZignOicpOwogICAgICBpZiAoaWR4ICE9PSAtMSkgaGVhZGVyc1tsaW5lLnNsaWNlKDAsIGlkeCkudHJpbSgpLnRvTG93ZXJDYXNlKCldID0gbGluZS5zbGljZShpZHggKyAxKS50cmltKCk7CiAgICB9CiAgICBjb25zdCBkaXNwb3NpdGlvbiA9IGhlYWRlcnNbJ2NvbnRlbnQtZGlzcG9zaXRpb24nXSB8fCAnJzsKICAgIGNvbnN0IG5hbWVNYXRjaCA9IC9uYW1lPSIoW14iXSopIi8uZXhlYyhkaXNwb3NpdGlvbik7CiAgICBjb25zdCBmaWxlbmFtZU1hdGNoID0gL2ZpbGVuYW1lPSIoW14iXSopIi8uZXhlYyhkaXNwb3NpdGlvbik7CgogICAgcGFydHMucHVzaCh7CiAgICAgIG5hbWU6IG5hbWVNYXRjaCA/IG5hbWVNYXRjaFsxXSA6IG51bGwsCiAgICAgIGZpbGVuYW1lOiBmaWxlbmFtZU1hdGNoID8gZmlsZW5hbWVNYXRjaFsxXSA6IG51bGwsCiAgICAgIGNvbnRlbnRUeXBlOiAoaGVhZGVyc1snY29udGVudC10eXBlJ10gfHwgJycpLnNwbGl0KCc7JylbMF0udHJpbSgpLnRvTG93ZXJDYXNlKCksCiAgICAgIGRhdGE6IGJ1ZmZlci5zbGljZShib2R5U3RhcnQsIGJvZHlFbmQpLAogICAgfSk7CgogICAgc3RhcnQgPSBuZXh0ICsgZGVsaW1pdGVyLmxlbmd0aDsKICB9CiAgcmV0dXJuIHBhcnRzOwp9Cgphc3luYyBmdW5jdGlvbiBoYW5kbGVVcGxvYWQocmVxLCByZXMpIHsKICBjb25zdCBjb250ZW50VHlwZSA9IHJlcS5oZWFkZXJzWydjb250ZW50LXR5cGUnXSB8fCAnJzsKICBjb25zdCBib3VuZGFyeU1hdGNoID0gL2JvdW5kYXJ5PSg/OiIoW14iXSspInwoW147XSspKS9pLmV4ZWMoY29udGVudFR5cGUpOwogIGlmICghY29udGVudFR5cGUuc3RhcnRzV2l0aCgnbXVsdGlwYXJ0L2Zvcm0tZGF0YScpIHx8ICFib3VuZGFyeU1hdGNoKSB7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA0MDAsIHsgZXJyb3I6ICdleHBlY3RlZCBtdWx0aXBhcnQvZm9ybS1kYXRhJyB9KTsKICB9CiAgY29uc3QgYm91bmRhcnkgPSBib3VuZGFyeU1hdGNoWzFdIHx8IGJvdW5kYXJ5TWF0Y2hbMl07CgogIGxldCBib2R5OwogIHRyeSB7CiAgICBib2R5ID0gYXdhaXQgcmVhZEJvZHkocmVxLCBNQVhfQllURVMpOwogIH0gY2F0Y2ggKGVycikgewogICAgaWYgKGVyci5zdGF0dXNDb2RlID09PSA0MTMpIHJldHVybiBzZW5kSnNvbihyZXMsIDQxMywgeyBlcnJvcjogJ2ZpbGUgZXhjZWVkcyAxNU1CIGxpbWl0JyB9KTsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDQwMCwgeyBlcnJvcjogJ2NvdWxkIG5vdCByZWFkIHJlcXVlc3QgYm9keScgfSk7CiAgfQoKICBjb25zdCBwYXJ0cyA9IHBhcnNlTXVsdGlwYXJ0KGJvZHksIGJvdW5kYXJ5KTsKICBjb25zdCBmaWxlUGFydCA9IHBhcnRzLmZpbmQoKHApID0+IHAubmFtZSA9PT0gJ2ZpbGUnKTsKICBpZiAoIWZpbGVQYXJ0IHx8IGZpbGVQYXJ0LmRhdGEubGVuZ3RoID09PSAwKSB7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA0MDAsIHsgZXJyb3I6ICdtaXNzaW5nICJmaWxlIiBmaWVsZCcgfSk7CiAgfQoKICBjb25zdCBleHQgPSBBTExPV0VELmdldChmaWxlUGFydC5jb250ZW50VHlwZSk7CiAgaWYgKCFleHQpIHsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDQxNSwgeyBlcnJvcjogYHVuc3VwcG9ydGVkIGNvbnRlbnQgdHlwZTogJHtmaWxlUGFydC5jb250ZW50VHlwZSB8fCAndW5rbm93bid9YCB9KTsKICB9CiAgaWYgKGZpbGVQYXJ0LmRhdGEubGVuZ3RoID4gTUFYX0JZVEVTKSB7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA0MTMsIHsgZXJyb3I6ICdmaWxlIGV4Y2VlZHMgMTVNQiBsaW1pdCcgfSk7CiAgfQoKICBjb25zdCBub3cgPSBuZXcgRGF0ZSgpOwogIGNvbnN0IHllYXIgPSBTdHJpbmcobm93LmdldFVUQ0Z1bGxZZWFyKCkpOwogIGNvbnN0IG1vbnRoID0gU3RyaW5nKG5vdy5nZXRVVENNb250aCgpICsgMSkucGFkU3RhcnQoMiwgJzAnKTsKICBjb25zdCB1dWlkID0gcmFuZG9tVVVJRCgpOwogIGNvbnN0IHJlbERpciA9IHBhdGguam9pbigndXBsb2FkcycsIHllYXIsIG1vbnRoKTsKICBjb25zdCByZWxQYXRoID0gcGF0aC5qb2luKHJlbERpciwgYCR7dXVpZH0uJHtleHR9YCk7CiAgY29uc3QgYWJzRGlyID0gcGF0aC5qb2luKERBVEFfRElSLCByZWxEaXIpOwogIGNvbnN0IGFic1BhdGggPSBwYXRoLmpvaW4oREFUQV9ESVIsIHJlbFBhdGgpOwoKICB0cnkgewogICAgYXdhaXQgbWtkaXIoYWJzRGlyLCB7IHJlY3Vyc2l2ZTogdHJ1ZSB9KTsKICAgIGF3YWl0IHdyaXRlRmlsZShhYnNQYXRoLCBmaWxlUGFydC5kYXRhKTsKICB9IGNhdGNoIChlcnIpIHsKICAgIGNvbnNvbGUuZXJyb3IoJ3dyaXRlIGZhaWxlZCcsIGVycik7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA1MDAsIHsgZXJyb3I6ICdmYWlsZWQgdG8gc3RvcmUgZmlsZScgfSk7CiAgfQoKICBjb25zdCB1cmwgPSBgJHtQVUJMSUNfQkFTRX0vbWVkaWEvJHtyZWxQYXRoLnNwbGl0KHBhdGguc2VwKS5qb2luKCcvJyl9YDsKICByZXR1cm4gc2VuZEpzb24ocmVzLCAyMDAsIHsgdXJsIH0pOwp9Cgphc3luYyBmdW5jdGlvbiBoYW5kbGVTZXJ2ZShyZXEsIHJlcywgcGF0aG5hbWUpIHsKICAvLyBwYXRobmFtZSBsaWtlIC9tZWRpYS91cGxvYWRzLzIwMjYvMDcvPHV1aWQ+LnBuZwogIGNvbnN0IHJlbCA9IGRlY29kZVVSSUNvbXBvbmVudChwYXRobmFtZS5yZXBsYWNlKC9eXC9tZWRpYVwvLywgJycpKTsKICAvLyBHdWFyZCBhZ2FpbnN0IHRyYXZlcnNhbC4KICBpZiAocmVsLmluY2x1ZGVzKCcuLicpIHx8IHJlbC5pbmNsdWRlcygnXDAnKSkgewogICAgcmV0dXJuIHNlbmRKc29uKHJlcywgNDAwLCB7IGVycm9yOiAnYmFkIHBhdGgnIH0pOwogIH0KICBjb25zdCBhYnMgPSBwYXRoLmpvaW4oREFUQV9ESVIsIHJlbCk7CiAgaWYgKCFhYnMuc3RhcnRzV2l0aChwYXRoLmpvaW4oREFUQV9ESVIsICd1cGxvYWRzJykgKyBwYXRoLnNlcCkpIHsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDQwNCwgeyBlcnJvcjogJ25vdCBmb3VuZCcgfSk7CiAgfQogIHRyeSB7CiAgICBjb25zdCBpbmZvID0gYXdhaXQgc3RhdChhYnMpOwogICAgaWYgKCFpbmZvLmlzRmlsZSgpKSB0aHJvdyBPYmplY3QuYXNzaWduKG5ldyBFcnJvcignbmYnKSwgeyBjb2RlOiAnRU5PRU5UJyB9KTsKICAgIGNvbnN0IGV4dCA9IHBhdGguZXh0bmFtZShhYnMpLnNsaWNlKDEpLnRvTG93ZXJDYXNlKCk7CiAgICBjb25zdCBib2R5ID0gYXdhaXQgcmVhZEZpbGUoYWJzKTsKICAgIHJlcy53cml0ZUhlYWQoMjAwLCB7CiAgICAgICdjb250ZW50LXR5cGUnOiBTRVJWRV9UWVBFUy5nZXQoZXh0KSB8fCAnYXBwbGljYXRpb24vb2N0ZXQtc3RyZWFtJywKICAgICAgJ2NvbnRlbnQtbGVuZ3RoJzogaW5mby5zaXplLAogICAgICAnY2FjaGUtY29udHJvbCc6ICdwdWJsaWMsIG1heC1hZ2U9MzE1MzYwMDAsIGltbXV0YWJsZScsCiAgICAgIC8vIEZpbGVzIGFyZSBwdWJsaWM7IENPUlMgbGV0cyB0aGUgU3Vic3RhY2sgZWRpdG9yIGZldGNoIG1lZGlhIGZvciByZS11cGxvYWQuCiAgICAgICdhY2Nlc3MtY29udHJvbC1hbGxvdy1vcmlnaW4nOiAnKicsCiAgICB9KTsKICAgIHJlcy5lbmQoYm9keSk7CiAgfSBjYXRjaCAoZXJyKSB7CiAgICBpZiAoZXJyLmNvZGUgPT09ICdFTk9FTlQnKSByZXR1cm4gc2VuZEpzb24ocmVzLCA0MDQsIHsgZXJyb3I6ICdub3QgZm91bmQnIH0pOwogICAgY29uc29sZS5lcnJvcignc2VydmUgZmFpbGVkJywgZXJyKTsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDUwMCwgeyBlcnJvcjogJ3NlcnZlciBlcnJvcicgfSk7CiAgfQp9Cgpjb25zdCBzZXJ2ZXIgPSBjcmVhdGVTZXJ2ZXIoKHJlcSwgcmVzKSA9PiB7CiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXEudXJsLCBgaHR0cDovLyR7cmVxLmhlYWRlcnMuaG9zdCB8fCAnbG9jYWxob3N0J31gKTsKICBjb25zdCB7IHBhdGhuYW1lIH0gPSB1cmw7CgogIGlmIChwYXRobmFtZSA9PT0gJy9tZWRpYS9oZWFsdGgnIHx8IHBhdGhuYW1lID09PSAnL2hlYWx0aCcpIHsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDIwMCwgeyBvazogdHJ1ZSB9KTsKICB9CiAgaWYgKHJlcS5tZXRob2QgPT09ICdQT1NUJyAmJiBwYXRobmFtZSA9PT0gJy9tZWRpYS91cGxvYWQnKSB7CiAgICByZXR1cm4gaGFuZGxlVXBsb2FkKHJlcSwgcmVzKTsKICB9CiAgaWYgKHJlcS5tZXRob2QgPT09ICdHRVQnICYmIHBhdGhuYW1lLnN0YXJ0c1dpdGgoJy9tZWRpYS91cGxvYWRzLycpKSB7CiAgICByZXR1cm4gaGFuZGxlU2VydmUocmVxLCByZXMsIHBhdGhuYW1lKTsKICB9CiAgcmV0dXJuIHNlbmRKc29uKHJlcywgNDA0LCB7IGVycm9yOiAnbm90IGZvdW5kJyB9KTsKfSk7CgpzZXJ2ZXIubGlzdGVuKFBPUlQsICgpID0+IHsKICBjb25zb2xlLmxvZyhgbWVkaWEtdXBsb2FkIGxpc3RlbmluZyBvbiA6JHtQT1JUfSwgZGF0YSBkaXIgJHtEQVRBX0RJUn1gKTsKfSk7Cg== \ No newline at end of file