From 7ed47af8bc256f43e0754dcb447bd5e142aaca85 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 2 Sep 2022 22:24:33 +0200 Subject: [PATCH] Initial commit --- .gitignore | 133 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 23 +++++++ deno.json | 18 ++++++ deps.ts | 3 + lib/config.ts | 14 +++++ lib/crypto.ts | 24 ++++++++ lib/handler.ts | 88 ++++++++++++++++++++++++++ main.ts | 46 ++++++++++++++ 8 files changed, 349 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 deno.json create mode 100644 deps.ts create mode 100644 lib/config.ts create mode 100644 lib/crypto.ts create mode 100644 lib/handler.ts create mode 100644 main.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0390b95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + + +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4f1252f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.2.0 + hooks: + - id: check-case-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: local + hooks: + - id: deno-fmt + name: deno-fmt + entry: deno fmt -q + language: system + types_or: [javascript, jsx, ts, tsx, json, markdown] + - id: deno-lint + name: deno-lint + entry: deno lint -q + language: system + types_or: [javascript, jsx, ts, tsx, json, markdown] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..1b9fa79 --- /dev/null +++ b/deno.json @@ -0,0 +1,18 @@ +{ + "fmt": { + "options": { + "indentWidth": 4, + "lineWidth": 100 + }, + "files": { + "exclude": [ + ".vscode" + ] + } + }, + "lint": { + "rules": { + "exclude": ["no-explicit-any"] + } + } +} diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..07f339f --- /dev/null +++ b/deps.ts @@ -0,0 +1,3 @@ +export * as http from "https://deno.land/std@0.154.0/http/mod.ts"; +export * as log from "https://deno.land/std@0.154.0/log/mod.ts"; +export * as hex from "https://deno.land/std@0.154.0/encoding/hex.ts"; diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..bc49b28 --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,14 @@ +function get(key: string, def?: string): string { + const value = Deno.env.get(key) ?? def; + if (value !== undefined) { + return value; + } + throw new Error(`Missing environment variable '${key}'.`); +} + +export default { + debug: !!parseInt(get("DEBUG", "0")), + hostname: get("HOSTNAME", "127.0.0.1"), + port: parseInt(get("PORT", "8080")), + signKey: get("SIGN_KEY"), +}; diff --git a/lib/crypto.ts b/lib/crypto.ts new file mode 100644 index 0000000..3a5157c --- /dev/null +++ b/lib/crypto.ts @@ -0,0 +1,24 @@ +import config from "./config.ts"; +import { hex } from "../deps.ts"; + +const signKey = await crypto.subtle.importKey( + "raw", + hex.decode(new TextEncoder().encode(config.signKey)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], +); + +const encoder = new TextEncoder(); + +export async function verify(input: string, signature: string): Promise { + const signatureData = hex.decode(encoder.encode(signature)); + const inputData = encoder.encode(input); + return await crypto.subtle.verify("HMAC", signKey, signatureData, inputData); +} + +export async function sign(input: string): Promise { + const inputData = encoder.encode(input); + const sig = await crypto.subtle.sign("HMAC", signKey, inputData); + return new TextDecoder().decode(hex.encode(new Uint8Array(sig))); +} diff --git a/lib/handler.ts b/lib/handler.ts new file mode 100644 index 0000000..13e1f4a --- /dev/null +++ b/lib/handler.ts @@ -0,0 +1,88 @@ +import { verify } from "./crypto.ts"; +import { http, log } from "../deps.ts"; + +function filter(headers: Headers, json: any, config: UrlConfig): string | null { + const event = headers.get("x-github-event"); + const login: string = json.sender?.login?.toLowerCase() || ""; + if (["coveralls[bot]", "netlify[bot]", "pre-commit-ci[bot]"].some((n) => login.includes(n))) { + return "bot"; + } + + const branchMatch = /^refs\/heads\/(.*)$/.exec(json.ref); + if ( + event === "push" && branchMatch && + config.allowBranches && !config.allowBranches.includes(branchMatch[1]) + ) { + return `branch '${branchMatch[1]}' not in ${JSON.stringify(config.allowBranches)}`; + } + + return null; +} + +async function sendWebhook( + id: string, + token: string, + headers: HeadersInit, + body: string, +): Promise { + const url = `https://discord.com/api/webhooks/${id}/${token}/github?wait=1`; + log.info(`Sending webhook request to ${url}`); + const req = new Request(url, { + method: "POST", + headers: headers, + body: body, + }); + return await fetch(req); +} + +interface UrlConfig { + allowBranches?: string[]; +} + +function getUrlConfig(params: URLSearchParams): UrlConfig { + const config: UrlConfig = {}; + for (const [key, value] of params) { + switch (key) { + case "sig": + continue; + case "allowBranches": + config.allowBranches = value.split(","); + break; + default: + throw http.createHttpError(418, `Unknown config option: ${key}`); + } + } + return config; +} + +export default async function handle(req: Request): Promise { + if (req.method !== "POST") { + throw http.createHttpError(405); + } + + // split url into parts + const url = new URL(req.url); + const [, id, token] = url.pathname.split("/"); + const signature = url.searchParams.get("sig"); + if (!id || !token || !signature) { + throw http.createHttpError(400); + } + + // verify signature + if (!(await verify(`${id}/${token}`, signature))) { + throw http.createHttpError(403); + } + + // extract data + const urlConfig = getUrlConfig(url.searchParams); + const data = await req.text(); + const json = JSON.parse(data); + + // do the thing + const filterReason = filter(req.headers, json, urlConfig); + if (filterReason !== null) { + return new Response(`Ignored by webhook filter (reason: ${filterReason})`, { status: 203 }); + } + + return await sendWebhook(id, token, req.headers, data); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..3566960 --- /dev/null +++ b/main.ts @@ -0,0 +1,46 @@ +import { http, log } from "./deps.ts"; +import config from "./lib/config.ts"; +import handler from "./lib/handler.ts"; + +await log.setup({ + handlers: { + console: new log.handlers.ConsoleHandler("DEBUG", { + formatter: (rec) => `${rec.datetime.toISOString()} [${rec.levelName}] ${rec.msg}`, + }), + }, + loggers: { + default: { + level: config.debug ? "DEBUG" : "INFO", + handlers: ["console"], + }, + }, +}); + +async function handleRequest(req: Request, connInfo: http.ConnInfo): Promise { + let resp: Response; + try { + resp = await handler(req); + } catch (err) { + if (http.isHttpError(err) && err.expose) { + log.warning(`http error: ${err.message}`); + resp = new Response(err.message, { status: err.status }); + } else { + log.critical(err); + resp = new Response("Internal Server Error", { status: 500 }); + } + } + + const respLen = resp.headers.get("content-length") || 0; + const addr = connInfo.remoteAddr as Deno.NetAddr; + log.info( + `http: ${addr.hostname}:${addr.port} - ${req.method} ${req.url} ${resp.status} ${respLen}`, + ); + + return resp; +} + +log.info(`Starting webserver on ${config.hostname}:${config.port}`); +http.serve(handleRequest, { + hostname: config.hostname, + port: config.port, +});