diff --git a/server.js b/server.js index ffb4e18..f24c05b 100644 --- a/server.js +++ b/server.js @@ -51,16 +51,36 @@ const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; } // ---- helpers ------------------------------------------------------------------------------- -const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp' }; +const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.mp4': 'video/mp4' }; const SEC = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY' }; function json(res, code, body, extra) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(JSON.stringify(body)); } function sendFile(res, file, extra) { + // video streams with byte ranges (iOS Safari refuses to play without 206 support) + if (path.extname(file) === '.mp4') return sendRange(res, file, extra); fs.readFile(file, (err, buf) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(buf); }); } +function sendRange(res, file, extra) { + fs.stat(file, (err, st) => { + if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } + const size = st.size; const range = res.req && res.req.headers.range; + const head = Object.assign({ 'Content-Type': TYPES['.mp4'], 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' }, SEC, extra || {}); + let start = 0, end = size - 1, code = 200; + const m = range && /^bytes=(\d*)-(\d*)$/.exec(range); + if (m) { + start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2] || 0)); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : (m[1] ? size - 1 : size - 1); + if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); } + code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size; + } + head['Content-Length'] = end - start + 1; + res.writeHead(code, head); + if (res.req && res.req.method === 'HEAD') return res.end(); + fs.createReadStream(file, { start, end }).pipe(res); + }); +} function readBody(req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { resolve({}); } }); }); } const hits = new Map(); function limited(key, max, windowMs) { const now = Date.now(); const r = hits.get(key); if (!r || now > r.reset) { hits.set(key, { n: 1, reset: now + windowMs }); return false; } r.n++; return r.n > max; }