diff --git a/entrypoint.example.ts b/entrypoint.example.ts new file mode 100644 index 0000000..0cd6f3b --- /dev/null +++ b/entrypoint.example.ts @@ -0,0 +1,4 @@ +#!/bin/sh +//bin/true; exec deno run -A "$0" "$@" + +// TODO diff --git a/src/pipeline.ts b/src/pipeline.ts new file mode 100644 index 0000000..df068ab --- /dev/null +++ b/src/pipeline.ts @@ -0,0 +1,3 @@ +import { readStdin } from './stdin.ts'; + +console.log(await readStdin()); diff --git a/src/policies/anti-duplication-policy.ts b/src/policies/anti-duplication-policy.ts index a943749..a9f15bf 100755 --- a/src/policies/anti-duplication-policy.ts +++ b/src/policies/anti-duplication-policy.ts @@ -1,24 +1,16 @@ -#!/bin/sh -//bin/true; exec deno run -A "$0" "$@" -import { Keydb, readLines } from '../deps.ts'; +import { Keydb } from '../deps.ts'; -import type { InputMessage, OutputMessage } from '../types.ts'; +import type { Policy } from '../types.ts'; const ANTI_DUPLICATION_TTL = Number(Deno.env.get('ANTI_DUPLICATION_TTL') || 60000); const ANTI_DUPLICATION_MIN_LENGTH = Number(Deno.env.get('ANTI_DUPLICATION_MIN_LENGTH') || 50); -/** https://stackoverflow.com/a/8831937 */ -function hashCode(str: string): number { - let hash = 0; - for (let i = 0, len = str.length; i < len; i++) { - const chr = str.charCodeAt(i); - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - return hash; -} - -async function handleMessage(msg: InputMessage): Promise { +/** + * Prevent messages with the exact same content from being submitted repeatedly. + * It stores a hashcode for each content in an SQLite database and rate-limits them. + * Only messages that meet the minimum length criteria are selected. + */ +const antiDuplicationPolicy: Policy = async (msg) => { const { kind, content } = msg.event; if (kind === 1 && content.length >= ANTI_DUPLICATION_MIN_LENGTH) { @@ -42,8 +34,21 @@ async function handleMessage(msg: InputMessage): Promise { action: 'accept', msg: '', }; +}; + +/** + * Get a "good enough" unique identifier for this content. + * This algorithm was chosen because it's very fast with a low chance of collisions. + * https://stackoverflow.com/a/8831937 + */ +function hashCode(str: string): number { + let hash = 0; + for (let i = 0, len = str.length; i < len; i++) { + const chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; } -for await (const line of readLines(Deno.stdin)) { - console.log(JSON.stringify(await handleMessage(JSON.parse(line)))); -} +export default antiDuplicationPolicy; diff --git a/src/policies/hellthread-policy.ts b/src/policies/hellthread-policy.ts index 6842176..419415b 100755 --- a/src/policies/hellthread-policy.ts +++ b/src/policies/hellthread-policy.ts @@ -1,11 +1,9 @@ -#!/usr/bin/env -S deno run -import { readLines } from '../deps.ts'; - -import type { InputMessage, OutputMessage } from '../types.ts'; +import type { Policy } from '../types.ts'; const HELLTHREAD_LIMIT = Number(Deno.env.get('HELLTHREAD_LIMIT') || 100); -function handleMessage(msg: InputMessage): OutputMessage { +/** Reject messages that tag too many participants. */ +const hellthreadPolicy: Policy = (msg) => { if (msg.event.kind === 1) { const p = msg.event.tags.filter((tag) => tag[0] === 'p'); @@ -23,8 +21,6 @@ function handleMessage(msg: InputMessage): OutputMessage { action: 'accept', msg: '', }; -} +}; -for await (const line of readLines(Deno.stdin)) { - console.log(JSON.stringify(handleMessage(JSON.parse(line)))); -} +export default hellthreadPolicy; diff --git a/src/policies/noop-policy.ts b/src/policies/noop-policy.ts index 1942e3a..a930ef9 100755 --- a/src/policies/noop-policy.ts +++ b/src/policies/noop-policy.ts @@ -1,16 +1,13 @@ -#!/usr/bin/env -S deno run -import { readLines } from '../deps.ts'; +import type { Policy } from '../types.ts'; -import type { InputMessage, OutputMessage } from '../types.ts'; +/** + * Minimal sample policy for demonstration purposes. + * Allows all events through. + */ +const noopPolicy: Policy = (msg) => ({ + id: msg.event.id, + action: 'accept', + msg: '', +}); -function handleMessage(msg: InputMessage): OutputMessage { - return { - id: msg.event.id, - action: 'accept', - msg: '', - }; -} - -for await (const line of readLines(Deno.stdin)) { - console.log(JSON.stringify(handleMessage(JSON.parse(line)))); -} +export default noopPolicy; diff --git a/src/policies/rate-limit-policy.ts b/src/policies/rate-limit-policy.ts index 906f242..a6810bb 100755 --- a/src/policies/rate-limit-policy.ts +++ b/src/policies/rate-limit-policy.ts @@ -1,15 +1,18 @@ -#!/bin/sh -//bin/true; exec deno run -A "$0" "$@" -import { Keydb, readLines } from '../deps.ts'; +import { Keydb } from '../deps.ts'; -import type { InputMessage, OutputMessage } from '../types.ts'; +import type { Policy } from '../types.ts'; const IP_WHITELIST = (Deno.env.get('IP_WHITELIST') || '').split(','); const RATE_LIMIT_INTERVAL = Number(Deno.env.get('RATE_LIMIT_INTERVAL') || 60000); const RATE_LIMIT_MAX = Number(Deno.env.get('RATE_LIMIT_MAX') || 10); -async function handleMessage(msg: InputMessage): Promise { +/** + * Rate-limits users by their IP address. + * IPs are stored in an SQLite database. If you are running internal services, + * it's a good idea to at least whitelist `127.0.0.1` etc. + */ +const rateLimitPolicy: Policy = async (msg) => { if ((msg.sourceType === 'IP4' || msg.sourceType === 'IP6') && !IP_WHITELIST.includes(msg.sourceInfo)) { const db = new Keydb('sqlite:///tmp/strfry-rate-limit-policy.sqlite3'); const count = await db.get(msg.sourceInfo) || 0; @@ -29,8 +32,6 @@ async function handleMessage(msg: InputMessage): Promise { action: 'accept', msg: '', }; -} +}; -for await (const line of readLines(Deno.stdin)) { - console.log(JSON.stringify(await handleMessage(JSON.parse(line)))); -} +export default rateLimitPolicy; diff --git a/src/policies/read-only-policy.ts b/src/policies/read-only-policy.ts index 881a9a8..996c27b 100755 --- a/src/policies/read-only-policy.ts +++ b/src/policies/read-only-policy.ts @@ -1,17 +1,10 @@ -#!/bin/sh -//bin/true; exec deno run -A "$0" "$@" -import { readLines } from '../deps.ts'; +import type { Policy } from '../types.ts'; -import type { InputMessage, OutputMessage } from '../types.ts'; +/** This policy rejects all messages. */ +const readOnlyPolicy: Policy = (msg) => ({ + id: msg.event.id, + action: 'reject', + msg: 'The relay is read-only.', +}); -function handleMessage(msg: InputMessage): OutputMessage { - return { - id: msg.event.id, - action: 'reject', - msg: 'The relay is set to read-only.', - }; -} - -for await (const line of readLines(Deno.stdin)) { - console.log(JSON.stringify(handleMessage(JSON.parse(line)))); -} +export default readOnlyPolicy; diff --git a/src/stdin.ts b/src/stdin.ts new file mode 100644 index 0000000..3305d19 --- /dev/null +++ b/src/stdin.ts @@ -0,0 +1,14 @@ +import { readLines } from './deps.ts'; + +import type { InputMessage } from './types.ts'; + +/** + * Get the first line from stdin. + * Can only be read ONCE, or else it returns undefined. + */ +async function readStdin(): Promise { + const { value } = await readLines(Deno.stdin).next(); + return JSON.parse(value); +} + +export { readStdin }; diff --git a/src/types.ts b/src/types.ts index 473a3f7..8f3f4a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,4 +22,6 @@ interface Event { created_at: number; } -export type { Event, InputMessage, OutputMessage }; +type Policy = (msg: InputMessage) => Promise | OutputMessage; + +export type { Event, InputMessage, OutputMessage, Policy };