diff --git a/server.mjs b/server.mjs index 7af4f81..207c367 100644 --- a/server.mjs +++ b/server.mjs @@ -1 +1,223 @@ -IyEvdXNyL2Jpbi9lbnYgbm9kZQovLyBUaW55IHplcm8tZGVwZW5kZW5jeSBtZWRpYSB1cGxvYWQgKyBzZXJ2aW5nIHNlcnZpY2UgZm9yIFN1YnNwZWFrLgovLyBSb3V0ZXMgKHBhdGhzIGFycml2ZSB2ZXJiYXRpbSBmcm9tIFRyYWVmaWsgUGF0aFByZWZpeCwgbm90aGluZyBzdHJpcHBlZCk6Ci8vICAgUE9TVCAvbWVkaWEvdXBsb2FkICAgICAgICAtPiBtdWx0aXBhcnQvZm9ybS1kYXRhIGZpZWxkICJmaWxlIjsgcmV0dXJucyB7dXJsfQovLyAgIEdFVCAgL21lZGlhL3VwbG9hZHMvLi4uICAgLT4gc2VydmVzIHN0b3JlZCBmaWxlIChwdWJsaWMsIGxvbmcgY2FjaGUpCi8vICAgR0VUICAvbWVkaWEvaGVhbHRoICAgICAgICAtPiBoZWFsdGggcHJvYmUKLy8gQXV0aCBpcyBlbmZvcmNlZCBieSBUcmFlZmlrIGJhc2ljYXV0aCBpbiBmcm9udCBvZiAvbWVkaWEvdXBsb2FkOyB0aGlzIGFwcCB0cnVzdHMgdGhhdC4KCmltcG9ydCB7IGNyZWF0ZVNlcnZlciB9IGZyb20gJ25vZGU6aHR0cCc7CmltcG9ydCB7IHJlYWRGaWxlLCB3cml0ZUZpbGUsIG1rZGlyLCBzdGF0IH0gZnJvbSAnbm9kZTpmcy9wcm9taXNlcyc7CmltcG9ydCB7IHJhbmRvbVVVSUQgfSBmcm9tICdub2RlOmNyeXB0byc7CmltcG9ydCBwYXRoIGZyb20gJ25vZGU6cGF0aCc7Cgpjb25zdCBQT1JUID0gTnVtYmVyKHByb2Nlc3MuZW52LlBPUlQgfHwgNTE3OCk7CmNvbnN0IERBVEFfRElSID0gcHJvY2Vzcy5lbnYuTUVESUFfREFUQV9ESVIgfHwgJy9kYXRhL3VwbG9hZHMnOwpjb25zdCBQVUJMSUNfQkFTRSA9IHByb2Nlc3MuZW52LlBVQkxJQ19CQVNFIHx8ICdodHRwczovL3N1YnNwZWFrLmdlb3JnZTEuZGV2JzsKY29uc3QgTUFYX0JZVEVTID0gMTUgKiAxMDI0ICogMTAyNDsgLy8gMTUgTUIKCi8vIEFsbG93ZWQgY29udGVudCB0eXBlcyAtPiBjYW5vbmljYWwgZXh0ZW5zaW9uLgpjb25zdCBBTExPV0VEID0gbmV3IE1hcChbCiAgWydpbWFnZS9qcGVnJywgJ2pwZyddLAogIFsnaW1hZ2UvcG5nJywgJ3BuZyddLAogIFsnaW1hZ2Uvd2VicCcsICd3ZWJwJ10sCiAgWydpbWFnZS9naWYnLCAnZ2lmJ10sCiAgWydpbWFnZS9hdmlmJywgJ2F2aWYnXSwKICBbJ2F1ZGlvL21wZWcnLCAnbXAzJ10sCl0pOwoKY29uc3QgU0VSVkVfVFlQRVMgPSBuZXcgTWFwKFsKICBbJ2pwZycsICdpbWFnZS9qcGVnJ10sCiAgWydwbmcnLCAnaW1hZ2UvcG5nJ10sCiAgWyd3ZWJwJywgJ2ltYWdlL3dlYnAnXSwKICBbJ2dpZicsICdpbWFnZS9naWYnXSwKICBbJ2F2aWYnLCAnaW1hZ2UvYXZpZiddLAogIFsnbXAzJywgJ2F1ZGlvL21wZWcnXSwKXSk7CgpmdW5jdGlvbiBzZW5kSnNvbihyZXMsIHN0YXR1cywgb2JqKSB7CiAgY29uc3QgYm9keSA9IEpTT04uc3RyaW5naWZ5KG9iaik7CiAgcmVzLndyaXRlSGVhZChzdGF0dXMsIHsKICAgICdjb250ZW50LXR5cGUnOiAnYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD11dGYtOCcsCiAgICAnY29udGVudC1sZW5ndGgnOiBCdWZmZXIuYnl0ZUxlbmd0aChib2R5KSwKICB9KTsKICByZXMuZW5kKGJvZHkpOwp9CgovLyBSZWFkIHRoZSB3aG9sZSByZXF1ZXN0IGJvZHksIGVuZm9yY2luZyBNQVhfQllURVMuCi8vIE9uIG92ZXJmbG93IHdlIHN0b3AgYnVmZmVyaW5nIGFuZCBkcmFpbiB0aGUgcmVzdCBvZiB0aGUgc3RyZWFtIHNvIHdlIGNhbgovLyBzdGlsbCBzZW5kIGEgY2xlYW4gNDEzIEpTT04gcmVzcG9uc2UgKGRlc3Ryb3lpbmcgdGhlIHNvY2tldCA9PiA1MDIgYXQgcHJveHkpLgpmdW5jdGlvbiByZWFkQm9keShyZXEsIGxpbWl0KSB7CiAgcmV0dXJuIG5ldyBQcm9taXNlKChyZXNvbHZlLCByZWplY3QpID0+IHsKICAgIGNvbnN0IGNodW5rcyA9IFtdOwogICAgbGV0IHRvdGFsID0gMDsKICAgIGxldCBvdmVyID0gZmFsc2U7CiAgICByZXEub24oJ2RhdGEnLCAoY2h1bmspID0+IHsKICAgICAgdG90YWwgKz0gY2h1bmsubGVuZ3RoOwogICAgICBpZiAob3ZlcikgcmV0dXJuOwogICAgICBpZiAodG90YWwgPiBsaW1pdCkgewogICAgICAgIG92ZXIgPSB0cnVlOwogICAgICAgIGNodW5rcy5sZW5ndGggPSAwOwogICAgICAgIHJldHVybjsKICAgICAgfQogICAgICBjaHVua3MucHVzaChjaHVuayk7CiAgICB9KTsKICAgIHJlcS5vbignZW5kJywgKCkgPT4gewogICAgICBpZiAob3ZlcikgewogICAgICAgIGNvbnN0IGVyciA9IG5ldyBFcnJvcigndG9vIGxhcmdlJyk7CiAgICAgICAgZXJyLnN0YXR1c0NvZGUgPSA0MTM7CiAgICAgICAgcmVqZWN0KGVycik7CiAgICAgICAgcmV0dXJuOwogICAgICB9CiAgICAgIHJlc29sdmUoQnVmZmVyLmNvbmNhdChjaHVua3MpKTsKICAgIH0pOwogICAgcmVxLm9uKCdlcnJvcicsIChlcnIpID0+IHJlamVjdChlcnIpKTsKICB9KTsKfQoKLy8gTWluaW1hbCBtdWx0aXBhcnQvZm9ybS1kYXRhIHBhcnNlci4gUmV0dXJucyB0aGUgZmlyc3QgcGFydCBuYW1lZCAiZmlsZSIuCmZ1bmN0aW9uIHBhcnNlTXVsdGlwYXJ0KGJ1ZmZlciwgYm91bmRhcnkpIHsKICBjb25zdCBkZWxpbWl0ZXIgPSBCdWZmZXIuZnJvbShgLS0ke2JvdW5kYXJ5fWApOwogIGNvbnN0IHBhcnRzID0gW107CiAgbGV0IHN0YXJ0ID0gYnVmZmVyLmluZGV4T2YoZGVsaW1pdGVyKTsKICBpZiAoc3RhcnQgPT09IC0xKSByZXR1cm4gcGFydHM7CiAgc3RhcnQgKz0gZGVsaW1pdGVyLmxlbmd0aDsKCiAgd2hpbGUgKHN0YXJ0IDwgYnVmZmVyLmxlbmd0aCkgewogICAgLy8gIi0tIiBhZnRlciBib3VuZGFyeSBtYXJrcyB0aGUgZW5kLgogICAgaWYgKGJ1ZmZlcltzdGFydF0gPT09IDB4MmQgJiYgYnVmZmVyW3N0YXJ0ICsgMV0gPT09IDB4MmQpIGJyZWFrOwogICAgLy8gU2tpcCBDUkxGIGFmdGVyIGJvdW5kYXJ5LgogICAgaWYgKGJ1ZmZlcltzdGFydF0gPT09IDB4MGQgJiYgYnVmZmVyW3N0YXJ0ICsgMV0gPT09IDB4MGEpIHN0YXJ0ICs9IDI7CgogICAgY29uc3QgaGVhZGVyRW5kID0gYnVmZmVyLmluZGV4T2YoQnVmZmVyLmZyb20oJ1xyXG5cclxuJyksIHN0YXJ0KTsKICAgIGlmIChoZWFkZXJFbmQgPT09IC0xKSBicmVhazsKICAgIGNvbnN0IGhlYWRlclRleHQgPSBidWZmZXIuc2xpY2Uoc3RhcnQsIGhlYWRlckVuZCkudG9TdHJpbmcoJ3V0ZjgnKTsKICAgIGNvbnN0IGJvZHlTdGFydCA9IGhlYWRlckVuZCArIDQ7CgogICAgY29uc3QgbmV4dCA9IGJ1ZmZlci5pbmRleE9mKGRlbGltaXRlciwgYm9keVN0YXJ0KTsKICAgIGlmIChuZXh0ID09PSAtMSkgYnJlYWs7CiAgICAvLyBCb2R5IGVuZHMgMiBieXRlcyAoQ1JMRikgYmVmb3JlIHRoZSBuZXh0IGJvdW5kYXJ5IGRlbGltaXRlci4KICAgIGNvbnN0IGJvZHlFbmQgPSBuZXh0IC0gMjsKCiAgICBjb25zdCBoZWFkZXJzID0ge307CiAgICBmb3IgKGNvbnN0IGxpbmUgb2YgaGVhZGVyVGV4dC5zcGxpdCgnXHJcbicpKSB7CiAgICAgIGNvbnN0IGlkeCA9IGxpbmUuaW5kZXhPZignOicpOwogICAgICBpZiAoaWR4ICE9PSAtMSkgaGVhZGVyc1tsaW5lLnNsaWNlKDAsIGlkeCkudHJpbSgpLnRvTG93ZXJDYXNlKCldID0gbGluZS5zbGljZShpZHggKyAxKS50cmltKCk7CiAgICB9CiAgICBjb25zdCBkaXNwb3NpdGlvbiA9IGhlYWRlcnNbJ2NvbnRlbnQtZGlzcG9zaXRpb24nXSB8fCAnJzsKICAgIGNvbnN0IG5hbWVNYXRjaCA9IC9uYW1lPSIoW14iXSopIi8uZXhlYyhkaXNwb3NpdGlvbik7CiAgICBjb25zdCBmaWxlbmFtZU1hdGNoID0gL2ZpbGVuYW1lPSIoW14iXSopIi8uZXhlYyhkaXNwb3NpdGlvbik7CgogICAgcGFydHMucHVzaCh7CiAgICAgIG5hbWU6IG5hbWVNYXRjaCA/IG5hbWVNYXRjaFsxXSA6IG51bGwsCiAgICAgIGZpbGVuYW1lOiBmaWxlbmFtZU1hdGNoID8gZmlsZW5hbWVNYXRjaFsxXSA6IG51bGwsCiAgICAgIGNvbnRlbnRUeXBlOiAoaGVhZGVyc1snY29udGVudC10eXBlJ10gfHwgJycpLnNwbGl0KCc7JylbMF0udHJpbSgpLnRvTG93ZXJDYXNlKCksCiAgICAgIGRhdGE6IGJ1ZmZlci5zbGljZShib2R5U3RhcnQsIGJvZHlFbmQpLAogICAgfSk7CgogICAgc3RhcnQgPSBuZXh0ICsgZGVsaW1pdGVyLmxlbmd0aDsKICB9CiAgcmV0dXJuIHBhcnRzOwp9Cgphc3luYyBmdW5jdGlvbiBoYW5kbGVVcGxvYWQocmVxLCByZXMpIHsKICBjb25zdCBjb250ZW50VHlwZSA9IHJlcS5oZWFkZXJzWydjb250ZW50LXR5cGUnXSB8fCAnJzsKICBjb25zdCBib3VuZGFyeU1hdGNoID0gL2JvdW5kYXJ5PSg/OiIoW14iXSspInwoW147XSspKS9pLmV4ZWMoY29udGVudFR5cGUpOwogIGlmICghY29udGVudFR5cGUuc3RhcnRzV2l0aCgnbXVsdGlwYXJ0L2Zvcm0tZGF0YScpIHx8ICFib3VuZGFyeU1hdGNoKSB7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA0MDAsIHsgZXJyb3I6ICdleHBlY3RlZCBtdWx0aXBhcnQvZm9ybS1kYXRhJyB9KTsKICB9CiAgY29uc3QgYm91bmRhcnkgPSBib3VuZGFyeU1hdGNoWzFdIHx8IGJvdW5kYXJ5TWF0Y2hbMl07CgogIGxldCBib2R5OwogIHRyeSB7CiAgICBib2R5ID0gYXdhaXQgcmVhZEJvZHkocmVxLCBNQVhfQllURVMpOwogIH0gY2F0Y2ggKGVycikgewogICAgaWYgKGVyci5zdGF0dXNDb2RlID09PSA0MTMpIHJldHVybiBzZW5kSnNvbihyZXMsIDQxMywgeyBlcnJvcjogJ2ZpbGUgZXhjZWVkcyAxNU1CIGxpbWl0JyB9KTsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDQwMCwgeyBlcnJvcjogJ2NvdWxkIG5vdCByZWFkIHJlcXVlc3QgYm9keScgfSk7CiAgfQoKICBjb25zdCBwYXJ0cyA9IHBhcnNlTXVsdGlwYXJ0KGJvZHksIGJvdW5kYXJ5KTsKICBjb25zdCBmaWxlUGFydCA9IHBhcnRzLmZpbmQoKHApID0+IHAubmFtZSA9PT0gJ2ZpbGUnKTsKICBpZiAoIWZpbGVQYXJ0IHx8IGZpbGVQYXJ0LmRhdGEubGVuZ3RoID09PSAwKSB7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA0MDAsIHsgZXJyb3I6ICdtaXNzaW5nICJmaWxlIiBmaWVsZCcgfSk7CiAgfQoKICBjb25zdCBleHQgPSBBTExPV0VELmdldChmaWxlUGFydC5jb250ZW50VHlwZSk7CiAgaWYgKCFleHQpIHsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDQxNSwgeyBlcnJvcjogYHVuc3VwcG9ydGVkIGNvbnRlbnQgdHlwZTogJHtmaWxlUGFydC5jb250ZW50VHlwZSB8fCAndW5rbm93bid9YCB9KTsKICB9CiAgaWYgKGZpbGVQYXJ0LmRhdGEubGVuZ3RoID4gTUFYX0JZVEVTKSB7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA0MTMsIHsgZXJyb3I6ICdmaWxlIGV4Y2VlZHMgMTVNQiBsaW1pdCcgfSk7CiAgfQoKICBjb25zdCBub3cgPSBuZXcgRGF0ZSgpOwogIGNvbnN0IHllYXIgPSBTdHJpbmcobm93LmdldFVUQ0Z1bGxZZWFyKCkpOwogIGNvbnN0IG1vbnRoID0gU3RyaW5nKG5vdy5nZXRVVENNb250aCgpICsgMSkucGFkU3RhcnQoMiwgJzAnKTsKICBjb25zdCB1dWlkID0gcmFuZG9tVVVJRCgpOwogIGNvbnN0IHJlbERpciA9IHBhdGguam9pbigndXBsb2FkcycsIHllYXIsIG1vbnRoKTsKICBjb25zdCByZWxQYXRoID0gcGF0aC5qb2luKHJlbERpciwgYCR7dXVpZH0uJHtleHR9YCk7CiAgY29uc3QgYWJzRGlyID0gcGF0aC5qb2luKERBVEFfRElSLCByZWxEaXIpOwogIGNvbnN0IGFic1BhdGggPSBwYXRoLmpvaW4oREFUQV9ESVIsIHJlbFBhdGgpOwoKICB0cnkgewogICAgYXdhaXQgbWtkaXIoYWJzRGlyLCB7IHJlY3Vyc2l2ZTogdHJ1ZSB9KTsKICAgIGF3YWl0IHdyaXRlRmlsZShhYnNQYXRoLCBmaWxlUGFydC5kYXRhKTsKICB9IGNhdGNoIChlcnIpIHsKICAgIGNvbnNvbGUuZXJyb3IoJ3dyaXRlIGZhaWxlZCcsIGVycik7CiAgICByZXR1cm4gc2VuZEpzb24ocmVzLCA1MDAsIHsgZXJyb3I6ICdmYWlsZWQgdG8gc3RvcmUgZmlsZScgfSk7CiAgfQoKICBjb25zdCB1cmwgPSBgJHtQVUJMSUNfQkFTRX0vbWVkaWEvJHtyZWxQYXRoLnNwbGl0KHBhdGguc2VwKS5qb2luKCcvJyl9YDsKICByZXR1cm4gc2VuZEpzb24ocmVzLCAyMDAsIHsgdXJsIH0pOwp9Cgphc3luYyBmdW5jdGlvbiBoYW5kbGVTZXJ2ZShyZXEsIHJlcywgcGF0aG5hbWUpIHsKICAvLyBwYXRobmFtZSBsaWtlIC9tZWRpYS91cGxvYWRzLzIwMjYvMDcvPHV1aWQ+LnBuZwogIGNvbnN0IHJlbCA9IGRlY29kZVVSSUNvbXBvbmVudChwYXRobmFtZS5yZXBsYWNlKC9eXC9tZWRpYVwvLywgJycpKTsKICAvLyBHdWFyZCBhZ2FpbnN0IHRyYXZlcnNhbC4KICBpZiAocmVsLmluY2x1ZGVzKCcuLicpIHx8IHJlbC5pbmNsdWRlcygnXDAnKSkgewogICAgcmV0dXJuIHNlbmRKc29uKHJlcywgNDAwLCB7IGVycm9yOiAnYmFkIHBhdGgnIH0pOwogIH0KICBjb25zdCBhYnMgPSBwYXRoLmpvaW4oREFUQV9ESVIsIHJlbCk7CiAgaWYgKCFhYnMuc3RhcnRzV2l0aChwYXRoLmpvaW4oREFUQV9ESVIsICd1cGxvYWRzJykgKyBwYXRoLnNlcCkpIHsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDQwNCwgeyBlcnJvcjogJ25vdCBmb3VuZCcgfSk7CiAgfQogIHRyeSB7CiAgICBjb25zdCBpbmZvID0gYXdhaXQgc3RhdChhYnMpOwogICAgaWYgKCFpbmZvLmlzRmlsZSgpKSB0aHJvdyBPYmplY3QuYXNzaWduKG5ldyBFcnJvcignbmYnKSwgeyBjb2RlOiAnRU5PRU5UJyB9KTsKICAgIGNvbnN0IGV4dCA9IHBhdGguZXh0bmFtZShhYnMpLnNsaWNlKDEpLnRvTG93ZXJDYXNlKCk7CiAgICBjb25zdCBib2R5ID0gYXdhaXQgcmVhZEZpbGUoYWJzKTsKICAgIHJlcy53cml0ZUhlYWQoMjAwLCB7CiAgICAgICdjb250ZW50LXR5cGUnOiBTRVJWRV9UWVBFUy5nZXQoZXh0KSB8fCAnYXBwbGljYXRpb24vb2N0ZXQtc3RyZWFtJywKICAgICAgJ2NvbnRlbnQtbGVuZ3RoJzogaW5mby5zaXplLAogICAgICAnY2FjaGUtY29udHJvbCc6ICdwdWJsaWMsIG1heC1hZ2U9MzE1MzYwMDAsIGltbXV0YWJsZScsCiAgICAgIC8vIEZpbGVzIGFyZSBwdWJsaWM7IENPUlMgbGV0cyB0aGUgU3Vic3RhY2sgZWRpdG9yIGZldGNoIG1lZGlhIGZvciByZS11cGxvYWQuCiAgICAgICdhY2Nlc3MtY29udHJvbC1hbGxvdy1vcmlnaW4nOiAnKicsCiAgICB9KTsKICAgIHJlcy5lbmQoYm9keSk7CiAgfSBjYXRjaCAoZXJyKSB7CiAgICBpZiAoZXJyLmNvZGUgPT09ICdFTk9FTlQnKSByZXR1cm4gc2VuZEpzb24ocmVzLCA0MDQsIHsgZXJyb3I6ICdub3QgZm91bmQnIH0pOwogICAgY29uc29sZS5lcnJvcignc2VydmUgZmFpbGVkJywgZXJyKTsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDUwMCwgeyBlcnJvcjogJ3NlcnZlciBlcnJvcicgfSk7CiAgfQp9Cgpjb25zdCBzZXJ2ZXIgPSBjcmVhdGVTZXJ2ZXIoKHJlcSwgcmVzKSA9PiB7CiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXEudXJsLCBgaHR0cDovLyR7cmVxLmhlYWRlcnMuaG9zdCB8fCAnbG9jYWxob3N0J31gKTsKICBjb25zdCB7IHBhdGhuYW1lIH0gPSB1cmw7CgogIGlmIChwYXRobmFtZSA9PT0gJy9tZWRpYS9oZWFsdGgnIHx8IHBhdGhuYW1lID09PSAnL2hlYWx0aCcpIHsKICAgIHJldHVybiBzZW5kSnNvbihyZXMsIDIwMCwgeyBvazogdHJ1ZSB9KTsKICB9CiAgaWYgKHJlcS5tZXRob2QgPT09ICdQT1NUJyAmJiBwYXRobmFtZSA9PT0gJy9tZWRpYS91cGxvYWQnKSB7CiAgICByZXR1cm4gaGFuZGxlVXBsb2FkKHJlcSwgcmVzKTsKICB9CiAgaWYgKHJlcS5tZXRob2QgPT09ICdHRVQnICYmIHBhdGhuYW1lLnN0YXJ0c1dpdGgoJy9tZWRpYS91cGxvYWRzLycpKSB7CiAgICByZXR1cm4gaGFuZGxlU2VydmUocmVxLCByZXMsIHBhdGhuYW1lKTsKICB9CiAgcmV0dXJuIHNlbmRKc29uKHJlcywgNDA0LCB7IGVycm9yOiAnbm90IGZvdW5kJyB9KTsKfSk7CgpzZXJ2ZXIubGlzdGVuKFBPUlQsICgpID0+IHsKICBjb25zb2xlLmxvZyhgbWVkaWEtdXBsb2FkIGxpc3RlbmluZyBvbiA6JHtQT1JUfSwgZGF0YSBkaXIgJHtEQVRBX0RJUn1gKTsKfSk7Cg== \ No newline at end of file +#!/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 content types -> canonical extension. +const ALLOWED = new Map([ + ['image/jpeg', 'jpg'], + ['image/png', 'png'], + ['image/webp', 'webp'], + ['image/gif', 'gif'], + ['image/avif', 'avif'], + ['audio/mpeg', 'mp3'], +]); + +const SERVE_TYPES = new Map([ + ['jpg', 'image/jpeg'], + ['png', 'image/png'], + ['webp', 'image/webp'], + ['gif', 'image/gif'], + ['avif', 'image/avif'], + ['mp3', 'audio/mpeg'], +]); + +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', + // Files are public; CORS lets the Substack editor fetch media for re-upload. + 'access-control-allow-origin': '*', + }); + 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}`); +});