Merge branch 'overhaul' into 'develop'
Overhaul See merge request soapbox-pub/strfry-policies!1
This commit is contained in:
commit
75a97095d7
19
.gitlab-ci.yml
Normal file
19
.gitlab-ci.yml
Normal file
@ -0,0 +1,19 @@
|
||||
image: denoland/deno:1.32.1
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
fmt:
|
||||
stage: test
|
||||
script: deno fmt --check
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
script: deno lint
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script: deno test
|
176
README.md
176
README.md
@ -1,9 +1,183 @@
|
||||
# strfry policies
|
||||
|
||||
A collection of policies for the [strfry](https://github.com/hoytech/strfry) relay, written in Deno.
|
||||
A collection of policies for the [strfry](https://github.com/hoytech/strfry) Nostr relay, built in Deno.
|
||||
|
||||
For more information about installing these policies and how they work, see [Write policy plugins](https://github.com/hoytech/strfry/blob/master/docs/plugins.md).
|
||||
|
||||
This library introduces a model for writing policies and composing them in a pipeline. Policies are fully configurable and it's easy to add your own or install more from anywhere on the net.
|
||||
|
||||
## Getting started
|
||||
|
||||
To get up and running, you will need to install Deno on the same machine as strfry:
|
||||
|
||||
```sh
|
||||
sudo apt install -y unzip
|
||||
curl -fsSL https://deno.land/x/install/install.sh | sudo DENO_INSTALL=/usr/local sh
|
||||
```
|
||||
|
||||
Create an entrypoint file somewhere and make it executable:
|
||||
|
||||
```sh
|
||||
sudo touch /opt/strfry-policy.ts
|
||||
sudo chmod +x /opt/strfry-policy.ts
|
||||
```
|
||||
|
||||
Now you can write your policy. Here's a good starting point:
|
||||
|
||||
```ts
|
||||
#!/bin/sh
|
||||
//bin/true; exec deno run -A "$0" "$@"
|
||||
import {
|
||||
antiDuplicationPolicy,
|
||||
hellthreadPolicy,
|
||||
pipeline,
|
||||
rateLimitPolicy,
|
||||
readStdin,
|
||||
writeStdout,
|
||||
} from 'https://gitlab.com/soapbox-pub/strfry-policies/-/blob/develop/mod.ts';
|
||||
|
||||
const msg = await readStdin();
|
||||
|
||||
const result = await pipeline(msg, [
|
||||
[hellthreadPolicy, { limit: 100 }],
|
||||
[antiDuplicationPolicy, { ttl: 60000, minLength: 50 }],
|
||||
[rateLimitPolicy, { whitelist: ['127.0.0.1'] }],
|
||||
]);
|
||||
|
||||
writeStdout(result);
|
||||
```
|
||||
|
||||
Finally, edit `strfry.conf` and enable the policy:
|
||||
|
||||
```diff
|
||||
writePolicy {
|
||||
# If non-empty, path to an executable script that implements the writePolicy plugin logic
|
||||
- plugin = ""
|
||||
+ plugin = "/opt/strfry-policy.ts"
|
||||
|
||||
# Number of seconds to search backwards for lookback events when starting the writePolicy plugin (0 for no lookback)
|
||||
lookbackSeconds = 0
|
||||
```
|
||||
|
||||
That's it! 🎉 Now you should check strfry logs to ensure everything is working okay.
|
||||
|
||||
## Writing your own policies
|
||||
|
||||
You can write a policy in TypeScript and host it anywhere. Deno allows importing modules by URL, making it easy to share policies.
|
||||
|
||||
Here is a basic sample policy:
|
||||
|
||||
```ts
|
||||
import type { Policy } from 'https://gitlab.com/soapbox-pub/strfry-policies/-/blob/develop/mod.ts';
|
||||
|
||||
/** Only American English is allowed. */
|
||||
const americanPolicy: Policy<void> = (msg) => {
|
||||
const { content } = msg.event;
|
||||
|
||||
const words = [
|
||||
'armour',
|
||||
'behaviour',
|
||||
'colour',
|
||||
'favourite',
|
||||
'flavour',
|
||||
'honour',
|
||||
'humour',
|
||||
'rumour',
|
||||
];
|
||||
|
||||
const isBritish = words.some((word) => content.toLowerCase().includes(word));
|
||||
|
||||
if (isBritish) {
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'reject',
|
||||
msg: 'Sorry, only American English is allowed on this server!',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default americanPolicy;
|
||||
```
|
||||
|
||||
Once you're done, you can either upload the file somewhere online or directly to your server. Then, update your pipeline:
|
||||
|
||||
```diff
|
||||
--- a/strfry-policy.ts
|
||||
+++ b/strfry-policy.ts
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readStdin,
|
||||
writeStdout,
|
||||
} from 'https://gitlab.com/soapbox-pub/strfry-policies/-/blob/develop/mod.ts';
|
||||
+import { americanPolicy } from 'https://gist.githubusercontent.com/alexgleason/5c2d084434fa0875397f44da198f4352/raw/3d3ce71c7ed9cef726f17c3a102c378b81760a45/american-policy.ts';
|
||||
|
||||
const msg = await readStdin();
|
||||
|
||||
@@ -17,6 +18,7 @@ const result = await pipeline(msg, [
|
||||
[hellthreadPolicy, { limit: 100 }],
|
||||
[antiDuplicationPolicy, { ttl: 60000, minLength: 50 }],
|
||||
[rateLimitPolicy, { whitelist: ['127.0.0.1'] }],
|
||||
+ americanPolicy,
|
||||
]);
|
||||
|
||||
writeStdout(result);
|
||||
```
|
||||
|
||||
### Policy options
|
||||
|
||||
The `Policy<Opts>` type is a generic that accepts options of any type. With opts, the policy above could be rewritten as:
|
||||
|
||||
```diff
|
||||
--- a/american-policy.ts
|
||||
+++ b/american-policy.ts
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Policy } from 'https://gitlab.com/soapbox-pub/strfry-policies/-/blob/develop/mod.ts';
|
||||
|
||||
+interface American {
|
||||
+ withGrey?: boolean;
|
||||
+}
|
||||
+
|
||||
/** Only American English is allowed. */
|
||||
-const americanPolicy: Policy<void> = (msg) => {
|
||||
+const americanPolicy: Policy<American> = (msg, opts) => {
|
||||
const { content } = msg.event;
|
||||
|
||||
const words = [
|
||||
@@ -15,6 +19,10 @@
|
||||
'rumour',
|
||||
];
|
||||
|
||||
+ if (opts?.withGrey) {
|
||||
+ words.push('grey');
|
||||
+ }
|
||||
+
|
||||
const isBritish = words.some((word) => content.toLowerCase().includes(word));
|
||||
|
||||
if (isBritish) {
|
||||
```
|
||||
|
||||
Then, in the pipeline:
|
||||
|
||||
```diff
|
||||
- americanPolicy,
|
||||
+ [americanPolicy, { withGrey: true }],
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
- You should not use `console.log` anywhere in your policies, as strfry expects stdout to be the strfry output message.
|
||||
|
||||
## Available policies
|
||||
|
||||
Please look directly at `src/policies` in this repo. The files include detailed JSDoc comments and it has good type support.
|
||||
|
||||
![Policies TypeScript](https://gitlab.com/soapbox-pub/strfry-policies/uploads/dfb993b3464af5ed78bb8e5db8677458/Kazam_screencast_00090.webm)
|
||||
|
||||
## License
|
||||
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
@ -1,72 +0,0 @@
|
||||
#!/bin/sh
|
||||
//bin/true; exec deno run -A "$0" "$@"
|
||||
import { readLines } from 'https://deno.land/std@0.178.0/io/mod.ts';
|
||||
import { Keydb } from 'https://deno.land/x/keydb@1.0.0/sqlite.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);
|
||||
|
||||
interface InputMessage {
|
||||
type: 'new' | 'lookback';
|
||||
event: Event;
|
||||
receivedAt: number;
|
||||
sourceType: 'IP4' | 'IP6' | 'Import' | 'Stream' | 'Sync';
|
||||
sourceInfo: string;
|
||||
}
|
||||
|
||||
interface OutputMessage {
|
||||
id: string;
|
||||
action: 'accept' | 'reject' | 'shadowReject';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
sig: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** 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<OutputMessage> {
|
||||
const { kind, content } = msg.event;
|
||||
|
||||
if (kind === 1 && content.length >= ANTI_DUPLICATION_MIN_LENGTH) {
|
||||
const db = new Keydb('sqlite:///tmp/strfry-anti-duplication-policy.sqlite3');
|
||||
const hash = String(hashCode(content));
|
||||
|
||||
if (await db.get(hash)) {
|
||||
await db.set(hash, 1, ANTI_DUPLICATION_TTL);
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'shadowReject',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
|
||||
await db.set(hash, 1, ANTI_DUPLICATION_TTL);
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
|
||||
for await (const line of readLines(Deno.stdin)) {
|
||||
console.log(JSON.stringify(await handleMessage(JSON.parse(line))));
|
||||
}
|
58
deno.lock
Normal file
58
deno.lock
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": "2",
|
||||
"remote": {
|
||||
"https://deno.land/std@0.181.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462",
|
||||
"https://deno.land/std@0.181.0/bytes/bytes_list.ts": "b4cbdfd2c263a13e8a904b12d082f6177ea97d9297274a4be134e989450dfa6a",
|
||||
"https://deno.land/std@0.181.0/bytes/concat.ts": "d26d6f3d7922e6d663dacfcd357563b7bf4a380ce5b9c2bbe0c8586662f25ce2",
|
||||
"https://deno.land/std@0.181.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219",
|
||||
"https://deno.land/std@0.181.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e",
|
||||
"https://deno.land/std@0.181.0/io/buf_reader.ts": "abeb92b18426f11d72b112518293a96aef2e6e55f80b84235e8971ac910affb5",
|
||||
"https://deno.land/std@0.181.0/io/buf_writer.ts": "48c33c8f00b61dcbc7958706741cec8e59810bd307bc6a326cbd474fe8346dfd",
|
||||
"https://deno.land/std@0.181.0/io/buffer.ts": "17f4410eaaa60a8a85733e8891349a619eadfbbe42e2f319283ce2b8f29723ab",
|
||||
"https://deno.land/std@0.181.0/io/copy_n.ts": "0cc7ce07c75130f6fc18621ec1911c36e147eb9570664fee0ea12b1988167590",
|
||||
"https://deno.land/std@0.181.0/io/limited_reader.ts": "6c9a216f8eef39c1ee2a6b37a29372c8fc63455b2eeb91f06d9646f8f759fc8b",
|
||||
"https://deno.land/std@0.181.0/io/mod.ts": "2665bcccc1fd6e8627cca167c3e92aaecbd9897556b6f69e6d258070ef63fd9b",
|
||||
"https://deno.land/std@0.181.0/io/multi_reader.ts": "9c2a0a31686c44b277e16da1d97b4686a986edcee48409b84be25eedbc39b271",
|
||||
"https://deno.land/std@0.181.0/io/read_delim.ts": "c02b93cc546ae8caad8682ae270863e7ace6daec24c1eddd6faabc95a9d876a3",
|
||||
"https://deno.land/std@0.181.0/io/read_int.ts": "7cb8bcdfaf1107586c3bacc583d11c64c060196cb070bb13ae8c2061404f911f",
|
||||
"https://deno.land/std@0.181.0/io/read_lines.ts": "c526c12a20a9386dc910d500f9cdea43cba974e853397790bd146817a7eef8cc",
|
||||
"https://deno.land/std@0.181.0/io/read_long.ts": "f0aaa420e3da1261c5d33c5e729f09922f3d9fa49f046258d4ff7a00d800c71e",
|
||||
"https://deno.land/std@0.181.0/io/read_range.ts": "28152daf32e43dd9f7d41d8466852b0d18ad766cd5c4334c91fef6e1b3a74eb5",
|
||||
"https://deno.land/std@0.181.0/io/read_short.ts": "805cb329574b850b84bf14a92c052c59b5977a492cd780c41df8ad40826c1a20",
|
||||
"https://deno.land/std@0.181.0/io/read_string_delim.ts": "5dc9f53bdf78e7d4ee1e56b9b60352238ab236a71c3e3b2a713c3d78472a53ce",
|
||||
"https://deno.land/std@0.181.0/io/slice_long_to_bytes.ts": "48d9bace92684e880e46aa4a2520fc3867f9d7ce212055f76ecc11b22f9644b7",
|
||||
"https://deno.land/std@0.181.0/io/string_reader.ts": "da0f68251b3d5b5112485dfd4d1b1936135c9b4d921182a7edaf47f74c25cc8f",
|
||||
"https://deno.land/std@0.181.0/io/string_writer.ts": "8a03c5858c24965a54c6538bed15f32a7c72f5704a12bda56f83a40e28e5433e",
|
||||
"https://deno.land/std@0.181.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea",
|
||||
"https://deno.land/std@0.181.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7",
|
||||
"https://deno.land/std@0.181.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f",
|
||||
"https://deno.land/std@0.181.0/types.d.ts": "dbaeb2c4d7c526db9828fc8df89d8aecf53b9ced72e0c4568f97ddd8cda616a4",
|
||||
"https://deno.land/std@0.86.0/async/deferred.ts": "f89ed49ba5e1dd0227c6bd5b23f017be46c3f92e4f0338dda08ff5aa54b9f6c9",
|
||||
"https://deno.land/std@0.86.0/async/delay.ts": "9de1d8d07d1927767ab7f82434b883f3d8294fb19cad819691a2ad81a728cf3d",
|
||||
"https://deno.land/std@0.86.0/async/mod.ts": "253b41c658d768613eacfb11caa0a9ca7148442f932018a45576f7f27554c853",
|
||||
"https://deno.land/std@0.86.0/async/mux_async_iterator.ts": "b9091909db04cdb0af6f7807677372f64c1488de6c4bd86004511b064bf230d6",
|
||||
"https://deno.land/std@0.86.0/async/pool.ts": "876f9e6815366cd017a3b4fbb9e9ae40310b1b6972f1bd541c94358bc11fb7e5",
|
||||
"https://deno.land/std@0.86.0/encoding/base64.ts": "eecae390f1f1d1cae6f6c6d732ede5276bf4b9cd29b1d281678c054dc5cc009e",
|
||||
"https://deno.land/std@0.86.0/encoding/hex.ts": "f952e0727bddb3b2fd2e6889d104eacbd62e92091f540ebd6459317a61932d9b",
|
||||
"https://deno.land/std@0.86.0/fmt/colors.ts": "d253f2367e5feebcf0f49676533949c09ea07a2d95fb74958f6f5c1c22fbec49",
|
||||
"https://deno.land/std@0.86.0/node/_utils.ts": "067c386d676432e9418808851e8de72df7774f009a652904f62358b4c94504cf",
|
||||
"https://deno.land/std@0.86.0/node/buffer.ts": "e98af24a3210d8fc3f022b6eb26d6e5bdf98fb0e02931e5983d20db9fed1b590",
|
||||
"https://deno.land/std@0.86.0/testing/_diff.ts": "961eaf6d9f5b0a8556c9d835bbc6fa74f5addd7d3b02728ba7936ff93364f7a3",
|
||||
"https://deno.land/std@0.86.0/testing/asserts.ts": "de942b2e1cb6dac1c1c4a7b698be017337e2e2cc2252ae0a6d215c5befde1e82",
|
||||
"https://deno.land/x/keydb@1.0.0/adapter.ts": "7492c993a6bd5ea033a3423e6383faa34bafd78292cfa344955e263d650112bc",
|
||||
"https://deno.land/x/keydb@1.0.0/jsonb.ts": "05fa1b45f43ea5e755a2271ef3dbeb3e4c64da7053c0c6c53e9d994fa98d9b72",
|
||||
"https://deno.land/x/keydb@1.0.0/keydb.ts": "616c4c866c9e11c29d5654d367468ed51b689565043f53fdeb5eb66f25138156",
|
||||
"https://deno.land/x/keydb@1.0.0/memory.ts": "f0ab6faf293c4ad3539fd3cf89c764d7f34d39d24e471ea59eebb5d1f5a510dc",
|
||||
"https://deno.land/x/keydb@1.0.0/sqlite.ts": "a16e0242077a0bd1bf027f5e5440778c237d5ff2e278aeebb2245c407a4e3b43",
|
||||
"https://deno.land/x/module_cache@0.0.1/mod.ts": "542ea337ec9c5ef4013a2c49a42d9faa9d161898c33629594ee526d883bc2cc6",
|
||||
"https://deno.land/x/sqlite@v2.3.2/build/sqlite.js": "24d7663da39aff93cb7a1c9fef28346d7c457ba30bba7fccfb211a94b4823c29",
|
||||
"https://deno.land/x/sqlite@v2.3.2/build/vfs.js": "ce3b69b82a1723cad2bf06dfee774f461bcc49502a20ec8b99198ceff46cd046",
|
||||
"https://deno.land/x/sqlite@v2.3.2/mod.ts": "1fc2fdc6aca3ffa8b3f4893c2858871e1bb24ab833d1ed732eba911ad44fb495",
|
||||
"https://deno.land/x/sqlite@v2.3.2/src/constants.ts": "6032b1a8b2e6ed186460385b83750deb8e9b7b8c6ff100e450605c814585186e",
|
||||
"https://deno.land/x/sqlite@v2.3.2/src/db.ts": "f5269a3014907b6758f99c6ac08b6dc1b448415703e745fb57ea3d3f8adb2a97",
|
||||
"https://deno.land/x/sqlite@v2.3.2/src/error.ts": "c305a89b28ab5b56e425074849c6544d23e855b92670935d9e965ee68f9a6a9e",
|
||||
"https://deno.land/x/sqlite@v2.3.2/src/row_objects.ts": "cf7ad165bb14c0fd346c46a7f3ea08043ace747d1abae3787bbe4b36be11b09c",
|
||||
"https://deno.land/x/sqlite@v2.3.2/src/rows.ts": "1b05730096f8df626bfc9d1597a776cc5ac7d989910a20711fa28c7a8daebb0e",
|
||||
"https://deno.land/x/sqlite@v2.3.2/src/wasm.ts": "9747b8c4de5542f2359a35461317f08a244fe8a0933c7e279caad27a0190e6bf"
|
||||
}
|
||||
}
|
22
entrypoint.example.ts
Normal file
22
entrypoint.example.ts
Normal file
@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
//bin/true; exec deno run -A "$0" "$@"
|
||||
import {
|
||||
antiDuplicationPolicy,
|
||||
hellthreadPolicy,
|
||||
noopPolicy,
|
||||
pipeline,
|
||||
rateLimitPolicy,
|
||||
readStdin,
|
||||
writeStdout,
|
||||
} from './mod.ts';
|
||||
|
||||
const msg = await readStdin();
|
||||
|
||||
const result = await pipeline(msg, [
|
||||
noopPolicy,
|
||||
[hellthreadPolicy, { limit: 100 }],
|
||||
[antiDuplicationPolicy, { ttl: 60000, minLength: 50 }],
|
||||
[rateLimitPolicy, { whitelist: ['127.0.0.1'] }],
|
||||
]);
|
||||
|
||||
writeStdout(result);
|
@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env -S deno run
|
||||
import { readLines } from 'https://deno.land/std@0.178.0/io/mod.ts';
|
||||
|
||||
const HELLTHREAD_LIMIT = Number(Deno.env.get('HELLTHREAD_LIMIT') || 100);
|
||||
|
||||
interface InputMessage {
|
||||
type: 'new' | 'lookback';
|
||||
event: Event;
|
||||
receivedAt: number;
|
||||
sourceType: 'IP4' | 'IP6' | 'Import' | 'Stream' | 'Sync';
|
||||
sourceInfo: string;
|
||||
}
|
||||
|
||||
interface OutputMessage {
|
||||
id: string;
|
||||
action: 'accept' | 'reject' | 'shadowReject';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
sig: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function handleMessage(msg: InputMessage): OutputMessage {
|
||||
if (msg.event.kind === 1) {
|
||||
const p = msg.event.tags.filter((tag) => tag[0] === 'p');
|
||||
|
||||
if (p.length > HELLTHREAD_LIMIT) {
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'reject',
|
||||
msg: `Event rejected due to ${p.length} "p" tags (${HELLTHREAD_LIMIT} is the limit).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
|
||||
for await (const line of readLines(Deno.stdin)) {
|
||||
console.log(JSON.stringify(handleMessage(JSON.parse(line))));
|
||||
}
|
11
mod.ts
Normal file
11
mod.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { default as antiDuplicationPolicy } from './src/policies/anti-duplication-policy.ts';
|
||||
export { default as hellthreadPolicy } from './src/policies/hellthread-policy.ts';
|
||||
export { default as noopPolicy } from './src/policies/noop-policy.ts';
|
||||
export { default as rateLimitPolicy } from './src/policies/rate-limit-policy.ts';
|
||||
export { default as readOnlyPolicy } from './src/policies/read-only-policy.ts';
|
||||
|
||||
export { readStdin, writeStdout } from './src/io.ts';
|
||||
export { default as pipeline } from './src/pipeline.ts';
|
||||
|
||||
export type { Event, InputMessage, OutputMessage, Policy } from './src/types.ts';
|
||||
export type { PolicyTuple } from './src/pipeline.ts';
|
@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env -S deno run
|
||||
import { readLines } from 'https://deno.land/std@0.178.0/io/mod.ts';
|
||||
|
||||
interface InputMessage {
|
||||
type: 'new' | 'lookback';
|
||||
event: Event;
|
||||
receivedAt: number;
|
||||
sourceType: 'IP4' | 'IP6' | 'Import' | 'Stream' | 'Sync';
|
||||
sourceInfo: string;
|
||||
}
|
||||
|
||||
interface OutputMessage {
|
||||
id: string;
|
||||
action: 'accept' | 'reject' | 'shadowReject';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
sig: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
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))));
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
#!/bin/sh
|
||||
//bin/true; exec deno run -A "$0" "$@"
|
||||
import { readLines } from 'https://deno.land/std@0.178.0/io/mod.ts';
|
||||
import { Keydb } from 'https://deno.land/x/keydb@1.0.0/sqlite.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);
|
||||
|
||||
interface InputMessage {
|
||||
type: 'new' | 'lookback';
|
||||
event: Event;
|
||||
receivedAt: number;
|
||||
sourceType: 'IP4' | 'IP6' | 'Import' | 'Stream' | 'Sync';
|
||||
sourceInfo: string;
|
||||
}
|
||||
|
||||
interface OutputMessage {
|
||||
id: string;
|
||||
action: 'accept' | 'reject' | 'shadowReject';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
sig: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
async function handleMessage(msg: InputMessage): Promise<OutputMessage> {
|
||||
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<number>(msg.sourceInfo) || 0;
|
||||
await db.set(msg.sourceInfo, count + 1, RATE_LIMIT_INTERVAL);
|
||||
|
||||
if (count >= RATE_LIMIT_MAX) {
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'reject',
|
||||
msg: 'Rate-limited.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
|
||||
for await (const line of readLines(Deno.stdin)) {
|
||||
console.log(JSON.stringify(await handleMessage(JSON.parse(line))));
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
#!/bin/sh
|
||||
//bin/true; exec deno run -A "$0" "$@"
|
||||
import { readLines } from 'https://deno.land/std@0.178.0/io/mod.ts';
|
||||
|
||||
interface InputMessage {
|
||||
type: 'new' | 'lookback';
|
||||
event: Event;
|
||||
receivedAt: number;
|
||||
sourceType: 'IP4' | 'IP6' | 'Import' | 'Stream' | 'Sync';
|
||||
sourceInfo: string;
|
||||
}
|
||||
|
||||
interface OutputMessage {
|
||||
id: string;
|
||||
action: 'accept' | 'reject' | 'shadowReject';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
sig: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
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))));
|
||||
}
|
3
src/deps.ts
Normal file
3
src/deps.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { readLines } from 'https://deno.land/std@0.181.0/io/mod.ts';
|
||||
export { assert } from 'https://deno.land/std@0.181.0/testing/asserts.ts';
|
||||
export { Keydb } from 'https://deno.land/x/keydb@1.0.0/sqlite.ts';
|
19
src/io.ts
Normal file
19
src/io.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { readLines } from './deps.ts';
|
||||
|
||||
import type { InputMessage, OutputMessage } from './types.ts';
|
||||
|
||||
/**
|
||||
* Get the first line from stdin.
|
||||
* Can only be read ONCE, or else it returns undefined.
|
||||
*/
|
||||
async function readStdin(): Promise<InputMessage> {
|
||||
const { value } = await readLines(Deno.stdin).next();
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
/** Writes the output message to stdout. */
|
||||
function writeStdout(msg: OutputMessage): void {
|
||||
console.log(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
export { readStdin, writeStdout };
|
39
src/pipeline.ts
Normal file
39
src/pipeline.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { InputMessage, OutputMessage, Policy } from './types.ts';
|
||||
|
||||
/** A policy function with opts to run it with. Used by the pipeline. */
|
||||
type PolicyTuple<P extends Policy = Policy> = [policy: P, opts?: InferPolicyOpts<P>];
|
||||
/** Infer opts from the policy. */
|
||||
type InferPolicyOpts<P> = P extends Policy<infer Opts> ? Opts : never;
|
||||
|
||||
/** Helper type for proper type inference of PolicyTuples. */
|
||||
// https://stackoverflow.com/a/75806165
|
||||
// https://stackoverflow.com/a/54608401
|
||||
type Policies<T extends any[]> = {
|
||||
[K in keyof T]: PolicyTuple<T[K]> | Policy<T[K]>;
|
||||
};
|
||||
|
||||
/** Processes messages through multiple policies, bailing early on rejection. */
|
||||
async function pipeline<T extends unknown[]>(msg: InputMessage, policies: [...Policies<T>]): Promise<OutputMessage> {
|
||||
for (const item of policies as (Policy | PolicyTuple)[]) {
|
||||
const [policy, opts] = toTuple(item);
|
||||
const result = await policy(msg, opts);
|
||||
if (result.action !== 'accept') {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Coerce item into a tuple if it isn't already. */
|
||||
function toTuple<P extends Policy>(item: PolicyTuple<P> | P): PolicyTuple<P> {
|
||||
return typeof item === 'function' ? [item] : item;
|
||||
}
|
||||
|
||||
export default pipeline;
|
||||
|
||||
export type { PolicyTuple };
|
66
src/policies/anti-duplication-policy.ts
Executable file
66
src/policies/anti-duplication-policy.ts
Executable file
@ -0,0 +1,66 @@
|
||||
import { Keydb } from '../deps.ts';
|
||||
|
||||
import type { Policy } from '../types.ts';
|
||||
|
||||
interface AntiDuplication {
|
||||
/** Time in ms until a message with this content may be posted again. Default: `60000` (1 minute). */
|
||||
ttl?: number;
|
||||
/** Note text under this limit will be skipped by the policy. Default: `50`. */
|
||||
minLength?: number;
|
||||
/** Database connection string. Default: `sqlite:///tmp/strfry-anti-duplication-policy.sqlite3` */
|
||||
databaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AntiDuplication> = async (msg, opts) => {
|
||||
const ttl = opts?.ttl ?? 60000;
|
||||
const minLength = opts?.minLength ?? 50;
|
||||
const databaseUrl = opts?.databaseUrl || 'sqlite:///tmp/strfry-anti-duplication-policy.sqlite3';
|
||||
|
||||
const { kind, content } = msg.event;
|
||||
|
||||
if (kind === 1 && content.length >= minLength) {
|
||||
const db = new Keydb(databaseUrl);
|
||||
const hash = String(hashCode(content));
|
||||
|
||||
if (await db.get(hash)) {
|
||||
await db.set(hash, 1, ttl);
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'shadowReject',
|
||||
msg: '',
|
||||
};
|
||||
}
|
||||
|
||||
await db.set(hash, 1, ttl);
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
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;
|
||||
}
|
||||
|
||||
export default antiDuplicationPolicy;
|
||||
|
||||
export type { AntiDuplication };
|
33
src/policies/hellthread-policy.ts
Executable file
33
src/policies/hellthread-policy.ts
Executable file
@ -0,0 +1,33 @@
|
||||
import type { Policy } from '../types.ts';
|
||||
|
||||
interface Hellthread {
|
||||
/** Total number of "p" tags a kind 1 note may have before it's rejected. Default: `100` */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Reject messages that tag too many participants. */
|
||||
const hellthreadPolicy: Policy<Hellthread> = (msg, opts) => {
|
||||
const limit = opts?.limit ?? 100;
|
||||
|
||||
if (msg.event.kind === 1) {
|
||||
const p = msg.event.tags.filter((tag) => tag[0] === 'p');
|
||||
|
||||
if (p.length > limit) {
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'reject',
|
||||
msg: `Event rejected due to ${p.length} "p" tags (${limit} is the limit).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
};
|
||||
|
||||
export default hellthreadPolicy;
|
||||
|
||||
export type { Hellthread };
|
13
src/policies/noop-policy.ts
Executable file
13
src/policies/noop-policy.ts
Executable file
@ -0,0 +1,13 @@
|
||||
import type { Policy } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Minimal sample policy for demonstration purposes.
|
||||
* Allows all events through.
|
||||
*/
|
||||
const noopPolicy: Policy<void> = (msg) => ({
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
});
|
||||
|
||||
export default noopPolicy;
|
50
src/policies/rate-limit-policy.ts
Executable file
50
src/policies/rate-limit-policy.ts
Executable file
@ -0,0 +1,50 @@
|
||||
import { Keydb } from '../deps.ts';
|
||||
|
||||
import type { Policy } from '../types.ts';
|
||||
|
||||
interface RateLimit {
|
||||
/** How often (ms) to check whether `max` has been exceeded. Default: `60000` (1 minute). */
|
||||
interval?: number;
|
||||
/** Max number of requests within the `interval` until the IP is rate-limited. Default: `10`. */
|
||||
max?: number;
|
||||
/** List of IP addresses to skip this policy. */
|
||||
whitelist?: string[];
|
||||
/** Database connection string. Default: `sqlite:///tmp/strfry-rate-limit-policy.sqlite3` */
|
||||
databaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RateLimit> = async (msg, opts) => {
|
||||
const interval = opts?.interval ?? 60000;
|
||||
const max = opts?.max ?? 10;
|
||||
const whitelist = opts?.whitelist || [];
|
||||
const databaseUrl = opts?.databaseUrl || 'sqlite:///tmp/strfry-rate-limit-policy.sqlite3';
|
||||
|
||||
if ((msg.sourceType === 'IP4' || msg.sourceType === 'IP6') && !whitelist.includes(msg.sourceInfo)) {
|
||||
const db = new Keydb(databaseUrl);
|
||||
const count = await db.get<number>(msg.sourceInfo) || 0;
|
||||
await db.set(msg.sourceInfo, count + 1, interval);
|
||||
|
||||
if (count >= max) {
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'reject',
|
||||
msg: 'Rate-limited.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.event.id,
|
||||
action: 'accept',
|
||||
msg: '',
|
||||
};
|
||||
};
|
||||
|
||||
export default rateLimitPolicy;
|
||||
|
||||
export type { RateLimit };
|
23
src/policies/read-only-policy.test.ts
Normal file
23
src/policies/read-only-policy.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { assert } from '../deps.ts';
|
||||
|
||||
import readOnlyPolicy from './read-only-policy.ts';
|
||||
|
||||
Deno.test('always rejects', async () => {
|
||||
const result = await readOnlyPolicy({
|
||||
event: {
|
||||
kind: 1,
|
||||
id: '',
|
||||
content: '',
|
||||
created_at: 0,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: [],
|
||||
},
|
||||
receivedAt: 0,
|
||||
sourceInfo: '127.0.0.1',
|
||||
sourceType: 'IP4',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
assert(result.action === 'reject');
|
||||
});
|
10
src/policies/read-only-policy.ts
Executable file
10
src/policies/read-only-policy.ts
Executable file
@ -0,0 +1,10 @@
|
||||
import type { Policy } from '../types.ts';
|
||||
|
||||
/** This policy rejects all messages. */
|
||||
const readOnlyPolicy: Policy<void> = (msg) => ({
|
||||
id: msg.event.id,
|
||||
action: 'reject',
|
||||
msg: 'The relay is read-only.',
|
||||
});
|
||||
|
||||
export default readOnlyPolicy;
|
51
src/types.ts
Normal file
51
src/types.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* strfry input message from stdin.
|
||||
* https://github.com/hoytech/strfry/blob/master/docs/plugins.md#input-messages
|
||||
*/
|
||||
interface InputMessage {
|
||||
/** Either `new` or `lookback`. */
|
||||
type: 'new' | 'lookback';
|
||||
/** The event posted by the client, with all the required fields such as `id`, `pubkey`, etc. */
|
||||
event: Event;
|
||||
/** Unix timestamp of when this event was received by the relay. */
|
||||
receivedAt: number;
|
||||
/** Where this event came from. Typically will be `IP4` or `IP6`, but in lookback can also be `Import`, `Stream`, or `Sync`. */
|
||||
sourceType: 'IP4' | 'IP6' | 'Import' | 'Stream' | 'Sync';
|
||||
/** Specifics of the event's source. Either an IP address or a relay URL (for stream/sync). */
|
||||
sourceInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* strfry output message to be printed as JSONL (minified JSON followed by a newline) to stdout.
|
||||
* https://github.com/hoytech/strfry/blob/master/docs/plugins.md#output-messages
|
||||
*/
|
||||
interface OutputMessage {
|
||||
/** The event ID taken from the `event.id` field of the input message. */
|
||||
id: string;
|
||||
/** Either `accept`, `reject`, or `shadowReject`. */
|
||||
action: 'accept' | 'reject' | 'shadowReject';
|
||||
/** The NIP-20 response message to be sent to the client. Only used for `reject`. */
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr event.
|
||||
* https://github.com/nostr-protocol/nips/blob/master/01.md
|
||||
*/
|
||||
interface Event<K extends number = number> {
|
||||
id: string;
|
||||
sig: string;
|
||||
kind: K;
|
||||
tags: string[][];
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy function in this library.
|
||||
* It accepts an input message, opts, and returns an output message.
|
||||
*/
|
||||
type Policy<Opts = unknown> = (msg: InputMessage, opts?: Opts) => Promise<OutputMessage> | OutputMessage;
|
||||
|
||||
export type { Event, InputMessage, OutputMessage, Policy };
|
Loading…
Reference in New Issue
Block a user