commit 71a0e9729ae66b22936064eb4dcefd2eaa81c5d2 Author: erin <git@ewin.moe> Date: Tue Feb 18 21:32:40 2025 -0500 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..35bcfed --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# discord webhook proxy server + +a shitty web server that lets you post messages into it and have them forwarded to a discord webhook. designed to hook up easily to a simple `<form>` on your website that randos can use to cyberbully you. + +## environment variables + +- `WEBHOOK_URL`: required. the discord webhook url you want your messages to be sent to. this can also be passed to the program as a command-line argument instead of an environment variable, if you want. +- `PORT`: default `80`. the port to listen on. +- `TRUST_X_FORWARDED_HOST`, `TRUST_X_FORWARDED_PROTO`, `TRUST_X_FORWARDED_FOR`, `TRUST_X_REAL_IP`: these correspond to headers commonly set by reverse proxies to convey information about the original request as they proxy it to the app. if you deploy this app behind a reverse proxy, set the variables that correspond to the headers your reverse proxy sets. (any non-empty value will be considered "true.") trusting a header that can be controlled directly by a requester will allow identity spoofing, so don't use these variables to trust headers that aren't explicitly set by your proxy (unless you're into that sort of thing). + +## api + +### `POST /send` + +accepts `Content-Type: application/x-www-form-urlencoded` (the default for HTML forms) or `application/json`. fields: + +- `content`: message content. max length 4096 +- `title`: optional. message title. max length 256 +- `color`: optional. sets the color of the embed displayed. a 6-digit case-insensitive hex color prefixed with a leading `#`; no other color formats are accepted. + +by default the server returns HTML responses so it's at lease somewhat friendly for use in a bare HTML `<form>`. if you want to be fancy you can submit the form with JS and set your request's `Accept` header to `application/json` to get slightly more structured responses (204 with no content on success, 400 with an `{"error": "the message"}` object on failure + +## example form + +```html +<form action="https://your.thing/send" method="post"> + <input type="text" name="title" id="title" /> + <textarea name="content"></textarea> + <button type="submit">Send</button> +</form> +``` diff --git a/example.html b/example.html new file mode 100644 index 0000000..c1d6cd0 --- /dev/null +++ b/example.html @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>bweep bwoop</title> + </head> + <body> + <!-- this will work fine with noscript --> + <form action="http://localhost:4567/send" method="post"> + <p><input type="text" name="title" /></p> + <p><textarea name="content"></textarea></p> + <p><input type="color" name="color" /></p> + <p><button type="submit">Send</button></p> + </form> + <!-- look at that progressive enhancement --> + <script> + document.querySelector('form').addEventListener('submit', (e) => { + e.preventDefault(); + fetch(`http://localhost:4567/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(Object.fromEntries(new FormData(e.target).entries())), + }).then(response => { + if (!response.ok) { + alert(`failed to send: ${error}`); + } + alert('sent!'); + e.target.reset(); + }).catch(error => { + alert('either your network died or you have a CORS issue, check the network log'); + }); + }); + </script> + </body> +</html> diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..c5e927a --- /dev/null +++ b/index.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert'; +import {createServer} from 'node:http'; +import {parse as parseQueryString} from 'node:querystring'; + +const webhook = process.argv[2] || process.env.WEBHOOK_URL; +assert(webhook, 'give me a webhook url please'); + +const port = process.env.PORT || 80; + +createServer(async (request, response) => { + const host = (process.env.TRUST_X_FORWARDED_HOST && request.headers['x-forwarded-host']) || `localhost:${port}`; + const proto = (process.env.TRUST_X_FORWARDED_PROTO && request.headers['x-forwarded-proto']) || 'http'; + const url = new URL(`${proto}://${host}${request.url}`); + + /** @type {import('node:http').OutgoingHttpHeaders} */ + const cors = { + 'Vary': 'Origin', + 'Access-Control-Allow-Origin': request.headers['origin'] || '*', + } + + try { + assert(url.pathname === '/send', 'you seem lost'); + if (request.method === 'OPTIONS') { + response.writeHead(request.headers['Access-Control-Request-Method'] === 'POST' ? 204 : 400, { + ...cors, + 'Access-Control-Allow-Methods': 'POST', + }).end(); + } + assert(request.method === 'POST', 'we dont do that here'); + assert( + ( + request.headers['content-type']?.startsWith('application/x-www-form-urlencoded') + || request.headers['content-type']?.startsWith('application/json') + ), + 'what the fuck am i looking at', + ); + + const body = await new Promise((resolve, reject) => { + let chunks = []; + request.on('data', chunk => chunks.push(chunk)); + request.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf-8')); + }); + request.on('error', () => { + request.removeAllListeners('data'); + request.removeAllListeners('end'); + reject(new Error('hello????')); + }); + }); + + let {title, content, color} = + request.headers['content-type'].includes('application/json') + ? JSON.parse(body) + : parseQueryString(body); + + assert( + ( + (typeof content === 'string' && content.length > 0) + && (title == null || typeof title === 'string') + && (color == null || typeof color === 'string') + ), + "jesse what the fuck are you talking about", + ); + assert((title == null || title.length <= 256) && (content.length <= 4096), "quit yapping"); + + if (color === '#000000') { + color = undefined; + } else if (color != null) { + assert(color[0] === '#', 'what are you trying to do here'); + color = parseInt(color.slice(1), 16); + assert(!isNaN(color) && color > 0 && color <= 0xFFFFFF, 'ok now youre just making shit up'); + } + + if (request.headers['accept'].startsWith('application/json')) { + response.writeHead(204, {'Content-Type': 'application/json', ...cors}).end(); + } else { + response.writeHead(200, {'Content-Type': 'text/html'}); + response.end(`<html><body><h1>submitted!</h1><script>document.write('<p><button onclick="window.history.back()">« go back</button></p>')</script></body></html>`); + } + + const ip = ( + (process.env.TRUST_X_FORWARDED_FOR && request.headers['x-forwarded-for']?.split(',')[0].trim()) + || (process.env.TRUST_X_REAL_IP && request.headers['x-real-ip']) + || request.socket.remoteAddress + || 'the void' + ); + + fetch(process.env.WEBHOOK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + embeds: [{ + title, + description: content, + color, + footer: {text: `${ip} • ${url}`}, + timestamp: new Date().toISOString(), + }], + }), + }); + } catch (error) { + if (request.headers['accept']?.startsWith('application/json')) { + response.writeHead(400, {'Content-Type': 'application/json', ...cors}); + response.end(JSON.stringify({error: error.message || 'you done fucked up'})); + } else { + response.writeHead(400, {'Content-Type': 'text/html'}); + response.end(`<html><body><h1>you done fucked up</h1><pre>${error.message}</pre></body></html>`); + } + } +}).listen(port);