Initial Version (Working relay implementing basic functionality)
This commit is contained in:
commit
aeae39df4d
15 changed files with 1272 additions and 0 deletions
5
.editorconfig
Normal file
5
.editorconfig
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
[*]
|
||||||
|
indent = 2
|
||||||
|
|
||||||
|
[*.sql]
|
||||||
|
indent = 4
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.env
|
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"deno.enable": true,
|
||||||
|
"sqltools.highlightQuery": true,
|
||||||
|
"sqltools.format": {
|
||||||
|
"language": "sql",
|
||||||
|
"keywordCase": "upper"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.formatOnPaste": true,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.formatOnSaveMode": "file",
|
||||||
|
"editor.formatOnType": true,
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||||
|
}
|
||||||
|
}
|
24
deno.json
Normal file
24
deno.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --allow-read --allow-write --allow-net --allow-ffi --allow-env --env-file --watch index.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
|
||||||
|
"@noble/ciphers": "jsr:@noble/ciphers@^1.2.1",
|
||||||
|
"@nostr/tools": "jsr:@nostr/tools@^2.10.4",
|
||||||
|
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.37.0",
|
||||||
|
"@nostrify/types": "jsr:@nostrify/types@^0.36.0",
|
||||||
|
"@std/encoding": "jsr:@std/encoding@^1.0.6",
|
||||||
|
"@std/fmt": "jsr:@std/fmt@^1.0.4",
|
||||||
|
"@std/log": "jsr:@std/log@^0.224.13",
|
||||||
|
"@types/deno": "npm:@types/deno@^2.0.0"
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"indentWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 80,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"semiColons": true,
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
||||||
|
}
|
278
deno.lock
generated
Normal file
278
deno.lock
generated
Normal file
|
@ -0,0 +1,278 @@
|
||||||
|
{
|
||||||
|
"version": "4",
|
||||||
|
"specifiers": {
|
||||||
|
"jsr:@db/sqlite@*": "0.12.0",
|
||||||
|
"jsr:@db/sqlite@0.12": "0.12.0",
|
||||||
|
"jsr:@denosaurs/plug@1": "1.0.6",
|
||||||
|
"jsr:@noble/ciphers@^1.2.1": "1.2.1",
|
||||||
|
"jsr:@nostr/tools@^2.10.4": "2.10.4",
|
||||||
|
"jsr:@nostrify/nostrify@*": "0.37.0",
|
||||||
|
"jsr:@nostrify/nostrify@0.37": "0.37.0",
|
||||||
|
"jsr:@nostrify/types@0.36": "0.36.0",
|
||||||
|
"jsr:@std/assert@0.217": "0.217.0",
|
||||||
|
"jsr:@std/assert@0.221": "0.221.0",
|
||||||
|
"jsr:@std/assert@0.224": "0.224.0",
|
||||||
|
"jsr:@std/crypto@0.224": "0.224.0",
|
||||||
|
"jsr:@std/encoding@*": "1.0.7",
|
||||||
|
"jsr:@std/encoding@0.221": "0.221.0",
|
||||||
|
"jsr:@std/encoding@0.224": "0.224.3",
|
||||||
|
"jsr:@std/encoding@^1.0.6": "1.0.7",
|
||||||
|
"jsr:@std/encoding@~0.224.1": "0.224.3",
|
||||||
|
"jsr:@std/fmt@0.221": "0.221.0",
|
||||||
|
"jsr:@std/fmt@^1.0.4": "1.0.5",
|
||||||
|
"jsr:@std/fmt@^1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/fs@*": "1.0.11",
|
||||||
|
"jsr:@std/fs@0.221": "0.221.0",
|
||||||
|
"jsr:@std/fs@^1.0.11": "1.0.11",
|
||||||
|
"jsr:@std/io@~0.225.2": "0.225.2",
|
||||||
|
"jsr:@std/log@*": "0.224.14",
|
||||||
|
"jsr:@std/log@~0.224.13": "0.224.14",
|
||||||
|
"jsr:@std/path@0.217": "0.217.0",
|
||||||
|
"jsr:@std/path@0.221": "0.221.0",
|
||||||
|
"jsr:@std/path@^1.0.8": "1.0.8",
|
||||||
|
"npm:@noble/ciphers@~0.5.1": "0.5.3",
|
||||||
|
"npm:@noble/curves@1.2.0": "1.2.0",
|
||||||
|
"npm:@noble/hashes@1.3.1": "1.3.1",
|
||||||
|
"npm:@scure/base@1.1.1": "1.1.1",
|
||||||
|
"npm:@scure/base@^1.1.6": "1.2.4",
|
||||||
|
"npm:@scure/bip32@1.3.1": "1.3.1",
|
||||||
|
"npm:@scure/bip32@^1.4.0": "1.6.2",
|
||||||
|
"npm:@scure/bip39@1.2.1": "1.2.1",
|
||||||
|
"npm:@scure/bip39@^1.3.0": "1.5.4",
|
||||||
|
"npm:@types/deno@2": "2.0.0",
|
||||||
|
"npm:lru-cache@^10.2.0": "10.4.3",
|
||||||
|
"npm:nostr-tools@^2.7.0": "2.10.4",
|
||||||
|
"npm:nostr-wasm@0.1.0": "0.1.0",
|
||||||
|
"npm:websocket-ts@^2.1.5": "2.1.5",
|
||||||
|
"npm:zod@^3.23.8": "3.24.1"
|
||||||
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@db/sqlite@0.12.0": {
|
||||||
|
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@denosaurs/plug",
|
||||||
|
"jsr:@std/path@0.217"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@denosaurs/plug@1.0.6": {
|
||||||
|
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/encoding@0.221",
|
||||||
|
"jsr:@std/fmt@0.221",
|
||||||
|
"jsr:@std/fs@0.221",
|
||||||
|
"jsr:@std/path@0.221"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@noble/ciphers@1.2.1": {
|
||||||
|
"integrity": "e8eba45a1a6fefa6e522872d2f6b2bcc40d6ff928bdacfb3add5e245c1656819"
|
||||||
|
},
|
||||||
|
"@nostr/tools@2.10.4": {
|
||||||
|
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
|
||||||
|
"dependencies": [
|
||||||
|
"npm:@noble/ciphers@~0.5.1",
|
||||||
|
"npm:@noble/curves",
|
||||||
|
"npm:@noble/hashes",
|
||||||
|
"npm:@scure/base@1.1.1",
|
||||||
|
"npm:@scure/bip32@1.3.1",
|
||||||
|
"npm:@scure/bip39@1.2.1",
|
||||||
|
"npm:nostr-wasm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@nostrify/nostrify@0.37.0": {
|
||||||
|
"integrity": "fa1439cc5e9a74986c4fb799a38a9ed7bd8663c62ae2a9363ca9b987548e27a0",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@nostrify/types",
|
||||||
|
"jsr:@std/crypto",
|
||||||
|
"jsr:@std/encoding@~0.224.1",
|
||||||
|
"npm:@scure/base@^1.1.6",
|
||||||
|
"npm:@scure/bip32@^1.4.0",
|
||||||
|
"npm:@scure/bip39@^1.3.0",
|
||||||
|
"npm:lru-cache",
|
||||||
|
"npm:nostr-tools",
|
||||||
|
"npm:websocket-ts",
|
||||||
|
"npm:zod"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@nostrify/types@0.36.0": {
|
||||||
|
"integrity": "b3413467debcbd298d217483df4e2aae6c335a34765c90ac7811cf7c637600e7"
|
||||||
|
},
|
||||||
|
"@std/assert@0.217.0": {
|
||||||
|
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
|
||||||
|
},
|
||||||
|
"@std/assert@0.221.0": {
|
||||||
|
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
|
||||||
|
},
|
||||||
|
"@std/assert@0.224.0": {
|
||||||
|
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
|
||||||
|
},
|
||||||
|
"@std/crypto@0.224.0": {
|
||||||
|
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.224",
|
||||||
|
"jsr:@std/encoding@0.224"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/encoding@0.221.0": {
|
||||||
|
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
|
||||||
|
},
|
||||||
|
"@std/encoding@0.224.3": {
|
||||||
|
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
||||||
|
},
|
||||||
|
"@std/encoding@1.0.7": {
|
||||||
|
"integrity": "f631247c1698fef289f2de9e2a33d571e46133b38d042905e3eac3715030a82d"
|
||||||
|
},
|
||||||
|
"@std/fmt@0.221.0": {
|
||||||
|
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
|
||||||
|
},
|
||||||
|
"@std/fmt@1.0.5": {
|
||||||
|
"integrity": "0cfab43364bc36650d83c425cd6d99910fc20c4576631149f0f987eddede1a4d"
|
||||||
|
},
|
||||||
|
"@std/fs@0.221.0": {
|
||||||
|
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.221",
|
||||||
|
"jsr:@std/path@0.221"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/fs@1.0.11": {
|
||||||
|
"integrity": "ba674672693340c5ebdd018b4fe1af46cb08741f42b4c538154e97d217b55bdd",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/path@^1.0.8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/io@0.225.2": {
|
||||||
|
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7"
|
||||||
|
},
|
||||||
|
"@std/log@0.224.14": {
|
||||||
|
"integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/fmt@^1.0.5",
|
||||||
|
"jsr:@std/fs@^1.0.11",
|
||||||
|
"jsr:@std/io"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/path@0.217.0": {
|
||||||
|
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.217"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/path@0.221.0": {
|
||||||
|
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.221"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/path@1.0.8": {
|
||||||
|
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"@noble/ciphers@0.5.3": {
|
||||||
|
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
|
||||||
|
},
|
||||||
|
"@noble/curves@1.1.0": {
|
||||||
|
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.3.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@noble/curves@1.2.0": {
|
||||||
|
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.3.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@noble/curves@1.8.1": {
|
||||||
|
"integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.7.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@noble/hashes@1.3.1": {
|
||||||
|
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
|
||||||
|
},
|
||||||
|
"@noble/hashes@1.3.2": {
|
||||||
|
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
|
||||||
|
},
|
||||||
|
"@noble/hashes@1.7.1": {
|
||||||
|
"integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ=="
|
||||||
|
},
|
||||||
|
"@scure/base@1.1.1": {
|
||||||
|
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
|
||||||
|
},
|
||||||
|
"@scure/base@1.2.4": {
|
||||||
|
"integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ=="
|
||||||
|
},
|
||||||
|
"@scure/bip32@1.3.1": {
|
||||||
|
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/curves@1.1.0",
|
||||||
|
"@noble/hashes@1.3.2",
|
||||||
|
"@scure/base@1.1.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@scure/bip32@1.6.2": {
|
||||||
|
"integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/curves@1.8.1",
|
||||||
|
"@noble/hashes@1.7.1",
|
||||||
|
"@scure/base@1.2.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@scure/bip39@1.2.1": {
|
||||||
|
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.3.2",
|
||||||
|
"@scure/base@1.1.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@scure/bip39@1.5.4": {
|
||||||
|
"integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/hashes@1.7.1",
|
||||||
|
"@scure/base@1.2.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@types/deno@2.0.0": {
|
||||||
|
"integrity": "sha512-O9/jRVlq93kqfkl4sYR5N7+Pz4ukzXVIbMnE/VgvpauNHsvjQ9iBVnJ3X0gAvMa2khcoFD8DSO7mQVCuiuDMPg=="
|
||||||
|
},
|
||||||
|
"lru-cache@10.4.3": {
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||||
|
},
|
||||||
|
"nostr-tools@2.10.4": {
|
||||||
|
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@noble/ciphers",
|
||||||
|
"@noble/curves@1.2.0",
|
||||||
|
"@noble/hashes@1.3.1",
|
||||||
|
"@scure/base@1.1.1",
|
||||||
|
"@scure/bip32@1.3.1",
|
||||||
|
"@scure/bip39@1.2.1",
|
||||||
|
"nostr-wasm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nostr-wasm@0.1.0": {
|
||||||
|
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
|
||||||
|
},
|
||||||
|
"websocket-ts@2.1.5": {
|
||||||
|
"integrity": "sha512-rCNl9w6Hsir1azFm/pbjBEFzLD/gi7Th5ZgOxMifB6STUfTSovYAzryWw0TRvSZ1+Qu1Z5Plw4z42UfTNA9idA=="
|
||||||
|
},
|
||||||
|
"zod@3.24.1": {
|
||||||
|
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@db/sqlite@0.12",
|
||||||
|
"jsr:@noble/ciphers@^1.2.1",
|
||||||
|
"jsr:@nostr/tools@^2.10.4",
|
||||||
|
"jsr:@nostrify/nostrify@0.37",
|
||||||
|
"jsr:@nostrify/types@0.36",
|
||||||
|
"jsr:@std/encoding@^1.0.6",
|
||||||
|
"jsr:@std/fmt@^1.0.4",
|
||||||
|
"jsr:@std/log@~0.224.13",
|
||||||
|
"npm:@types/deno@2"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
581
index.ts
Normal file
581
index.ts
Normal file
|
@ -0,0 +1,581 @@
|
||||||
|
import { NSchema as n } from "jsr:@nostrify/nostrify";
|
||||||
|
import type {
|
||||||
|
NostrClientREQ,
|
||||||
|
NostrEvent,
|
||||||
|
NostrFilter,
|
||||||
|
} from "jsr:@nostrify/types";
|
||||||
|
import {
|
||||||
|
getCCNPrivateKey,
|
||||||
|
getCCNPubkey,
|
||||||
|
isArray,
|
||||||
|
isLocalhost,
|
||||||
|
isValidJSON,
|
||||||
|
randomTimeUpTo2DaysInThePast,
|
||||||
|
} from "./utils.ts";
|
||||||
|
import * as nostrTools from "@nostr/tools";
|
||||||
|
import { nip44 } from "@nostr/tools";
|
||||||
|
import { randomBytes } from "@noble/ciphers/webcrypto";
|
||||||
|
import { encodeBase64 } from "jsr:@std/encoding@0.224/base64";
|
||||||
|
import { Database } from "jsr:@db/sqlite";
|
||||||
|
import { mixQuery, sql, sqlPartial } from "./utils/queries.ts";
|
||||||
|
import { log, setupLogger } from "./utils/logs.ts";
|
||||||
|
import { getEveFilePath } from "./utils/files.ts";
|
||||||
|
|
||||||
|
await setupLogger();
|
||||||
|
|
||||||
|
if (!Deno.env.has("ENCRYPTION_KEY")) {
|
||||||
|
log.error(
|
||||||
|
`Missing ENCRYPTION_KEY. Please set it in your env.\nA new one has been generated for you: ENCRYPTION_KEY="${
|
||||||
|
encodeBase64(
|
||||||
|
randomBytes(32),
|
||||||
|
)
|
||||||
|
}"`,
|
||||||
|
);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(await getEveFilePath("db"));
|
||||||
|
const pool = new nostrTools.SimplePool();
|
||||||
|
const relays = [
|
||||||
|
"wss://relay.arx-ccn.com/",
|
||||||
|
"wss://relay.dannymorabito.com/",
|
||||||
|
"wss://nos.lol/",
|
||||||
|
"wss://nostr.einundzwanzig.space/",
|
||||||
|
"wss://nostr.massmux.com/",
|
||||||
|
"wss://nostr.mom/",
|
||||||
|
"wss://nostr.wine/",
|
||||||
|
"wss://purplerelay.com/",
|
||||||
|
"wss://relay.damus.io/",
|
||||||
|
"wss://relay.goodmorningbitcoin.com/",
|
||||||
|
"wss://relay.lexingtonbitcoin.org/",
|
||||||
|
"wss://relay.nostr.band/",
|
||||||
|
"wss://relay.primal.net/",
|
||||||
|
"wss://relay.snort.social/",
|
||||||
|
"wss://strfry.iris.to/",
|
||||||
|
"wss://cache2.primal.net/v1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function runMigrations(db: Database, latestVersion: number) {
|
||||||
|
const migrations = Deno.readDirSync(`${import.meta.dirname}/migrations`);
|
||||||
|
for (const migrationFile of migrations) {
|
||||||
|
const migrationVersion = Number.parseInt(
|
||||||
|
migrationFile.name.split("-")[0],
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (migrationVersion > latestVersion) {
|
||||||
|
log.info(
|
||||||
|
`Running migration ${migrationFile.name} (version ${migrationVersion})`,
|
||||||
|
);
|
||||||
|
const start = Date.now();
|
||||||
|
const migrationSql = Deno.readTextFileSync(
|
||||||
|
`${import.meta.dirname}/migrations/${migrationFile.name}`,
|
||||||
|
);
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
try {
|
||||||
|
db.run(migrationSql);
|
||||||
|
const end = Date.now();
|
||||||
|
const durationMs = end - start;
|
||||||
|
sql`
|
||||||
|
INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status) VALUES (${migrationVersion}, ${migrationFile.name}, ${
|
||||||
|
new Date().toISOString()
|
||||||
|
}, ${durationMs}, 'success');
|
||||||
|
db.run("COMMIT TRANSACTION");
|
||||||
|
`(db);
|
||||||
|
} catch (e) {
|
||||||
|
db.run("ROLLBACK TRANSACTION");
|
||||||
|
const error = e instanceof Error
|
||||||
|
? e
|
||||||
|
: typeof e === "string"
|
||||||
|
? new Error(e)
|
||||||
|
: new Error(JSON.stringify(e));
|
||||||
|
const end = Date.now();
|
||||||
|
const durationMs = end - start;
|
||||||
|
sql`
|
||||||
|
INSERT INTO migration_history (migration_version, migration_name, executed_at, duration_ms, status, error_message) VALUES (${migrationVersion}, ${migrationFile.name}, ${
|
||||||
|
new Date().toISOString()
|
||||||
|
}, ${durationMs}, 'failed', ${error.message});
|
||||||
|
`(db);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
db.run("END TRANSACTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEncryptedEvent(
|
||||||
|
event: nostrTools.VerifiedEvent,
|
||||||
|
): Promise<nostrTools.VerifiedEvent> {
|
||||||
|
if (!event.id) throw new Error("Event must have an ID");
|
||||||
|
if (!event.sig) throw new Error("Event must be signed");
|
||||||
|
const ccnPubKey = await getCCNPubkey();
|
||||||
|
const ccnPrivateKey = await getCCNPrivateKey();
|
||||||
|
const randomPrivateKey = nostrTools.generateSecretKey();
|
||||||
|
const conversationKey = nip44.getConversationKey(randomPrivateKey, ccnPubKey);
|
||||||
|
const sealTemplate = {
|
||||||
|
kind: 13,
|
||||||
|
created_at: randomTimeUpTo2DaysInThePast(),
|
||||||
|
content: nip44.encrypt(JSON.stringify(event), conversationKey),
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
const seal = nostrTools.finalizeEvent(sealTemplate, ccnPrivateKey);
|
||||||
|
const giftWrapTemplate = {
|
||||||
|
kind: 1059,
|
||||||
|
created_at: randomTimeUpTo2DaysInThePast(),
|
||||||
|
content: nip44.encrypt(JSON.stringify(seal), conversationKey),
|
||||||
|
tags: [["p", ccnPubKey]],
|
||||||
|
};
|
||||||
|
const giftWrap = nostrTools.finalizeEvent(giftWrapTemplate, randomPrivateKey);
|
||||||
|
return giftWrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptEvent(
|
||||||
|
event: nostrTools.Event,
|
||||||
|
): Promise<nostrTools.VerifiedEvent> {
|
||||||
|
const ccnPrivkey = await getCCNPrivateKey();
|
||||||
|
|
||||||
|
if (event.kind !== 1059) {
|
||||||
|
throw new Error("Cannot decrypt event -- not a gift wrap");
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
|
||||||
|
const seal = JSON.parse(nip44.decrypt(event.content, conversationKey));
|
||||||
|
if (!seal) throw new Error("Cannot decrypt event -- no seal");
|
||||||
|
if (seal.kind !== 13) {
|
||||||
|
throw new Error("Cannot decrypt event subevent -- not a seal");
|
||||||
|
}
|
||||||
|
const content = JSON.parse(nip44.decrypt(seal.content, conversationKey));
|
||||||
|
return content as nostrTools.VerifiedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventAlreadyExistsException extends Error {}
|
||||||
|
|
||||||
|
function addEventToDb(
|
||||||
|
decryptedEvent: nostrTools.VerifiedEvent,
|
||||||
|
encryptedEvent: nostrTools.VerifiedEvent,
|
||||||
|
) {
|
||||||
|
const existingEvent = sql`
|
||||||
|
SELECT * FROM events WHERE id = ${decryptedEvent.id}
|
||||||
|
`(db)[0];
|
||||||
|
|
||||||
|
if (existingEvent) throw new EventAlreadyExistsException();
|
||||||
|
try {
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
sql`
|
||||||
|
INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES (
|
||||||
|
${decryptedEvent.id},
|
||||||
|
${encryptedEvent.id},
|
||||||
|
${decryptedEvent.pubkey},
|
||||||
|
${decryptedEvent.created_at},
|
||||||
|
${decryptedEvent.kind},
|
||||||
|
${decryptedEvent.content},
|
||||||
|
${decryptedEvent.sig},
|
||||||
|
unixepoch()
|
||||||
|
)
|
||||||
|
`(db);
|
||||||
|
if (decryptedEvent.tags) {
|
||||||
|
for (let i = 0; i < decryptedEvent.tags.length; i++) {
|
||||||
|
const tag = sql`
|
||||||
|
INSERT INTO event_tags(event_id, tag_name, tag_index) VALUES (
|
||||||
|
${decryptedEvent.id},
|
||||||
|
${decryptedEvent.tags[i][0]},
|
||||||
|
${i}
|
||||||
|
) RETURNING tag_id
|
||||||
|
`(db)[0];
|
||||||
|
for (let j = 1; j < decryptedEvent.tags[i].length; j++) {
|
||||||
|
sql`
|
||||||
|
INSERT INTO event_tags_values(tag_id, value_position, value) VALUES (
|
||||||
|
${tag.tag_id},
|
||||||
|
${j},
|
||||||
|
${decryptedEvent.tags[i][j]}
|
||||||
|
)
|
||||||
|
`(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.run("COMMIT TRANSACTION");
|
||||||
|
} catch (e) {
|
||||||
|
db.run("ROLLBACK TRANSACTION");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptedEventIsInDb(event: nostrTools.VerifiedEvent) {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM events WHERE original_id = ${event.id}
|
||||||
|
`(db)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupAndSubscribeToExternalEvents() {
|
||||||
|
const ccnPubkey = await getCCNPubkey();
|
||||||
|
|
||||||
|
const isInitialized = sql`
|
||||||
|
SELECT name FROM sqlite_master WHERE type='table' AND name='migration_history'
|
||||||
|
`(db)[0];
|
||||||
|
|
||||||
|
if (!isInitialized) runMigrations(db, -1);
|
||||||
|
|
||||||
|
const latestVersion = sql`
|
||||||
|
SELECT migration_version FROM migration_history WHERE status = 'success' ORDER BY migration_version DESC LIMIT 1
|
||||||
|
`(db)[0]?.migration_version ?? -1;
|
||||||
|
|
||||||
|
runMigrations(db, latestVersion);
|
||||||
|
|
||||||
|
pool.subscribeMany(
|
||||||
|
relays,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"#p": [ccnPubkey],
|
||||||
|
kinds: [1059],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
async onevent(event: nostrTools.Event) {
|
||||||
|
if (timer) {
|
||||||
|
timerCleaned = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (knownOriginalEvents.indexOf(event.id) >= 0) return;
|
||||||
|
if (!nostrTools.verifyEvent(event)) {
|
||||||
|
log.warn("Invalid event received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (encryptedEventIsInDb(event)) return;
|
||||||
|
const decryptedEvent = await decryptEvent(event);
|
||||||
|
try {
|
||||||
|
addEventToDb(decryptedEvent, event);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof EventAlreadyExistsException) return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let timerCleaned = false;
|
||||||
|
|
||||||
|
const knownOriginalEvents = sql`SELECT original_id FROM events`(db).flatMap(
|
||||||
|
(row) => row.original_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
// if nothing is found in 10 seconds, create a new CCN, TODO: change logic
|
||||||
|
const ccnCreationEventTemplate = {
|
||||||
|
kind: 0,
|
||||||
|
content: JSON.stringify({
|
||||||
|
display_name: "New CCN",
|
||||||
|
name: "New CCN",
|
||||||
|
bot: true,
|
||||||
|
}),
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [["p", ccnPubkey]],
|
||||||
|
};
|
||||||
|
const ccnCreationEvent = nostrTools.finalizeEvent(
|
||||||
|
ccnCreationEventTemplate,
|
||||||
|
await getCCNPrivateKey(),
|
||||||
|
);
|
||||||
|
const encryptedCCNCreationEvent = await createEncryptedEvent(
|
||||||
|
ccnCreationEvent,
|
||||||
|
);
|
||||||
|
if (timerCleaned) return; // in case we get an event before the timer is cleaned
|
||||||
|
await Promise.any(pool.publish(relays, encryptedCCNCreationEvent));
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupAndSubscribeToExternalEvents();
|
||||||
|
|
||||||
|
class UserConnection {
|
||||||
|
public socket: WebSocket;
|
||||||
|
public subscriptions: Map<string, NostrFilter[]>;
|
||||||
|
public db: Database;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
socket: WebSocket,
|
||||||
|
subscriptions: Map<string, NostrFilter[]>,
|
||||||
|
db: Database,
|
||||||
|
) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.subscriptions = subscriptions;
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersMatchingEvent(
|
||||||
|
event: NostrEvent,
|
||||||
|
connection: UserConnection,
|
||||||
|
): string[] {
|
||||||
|
const matching = [];
|
||||||
|
for (const subscription of connection.subscriptions.keys()) {
|
||||||
|
const filters = connection.subscriptions.get(subscription);
|
||||||
|
if (!filters) continue;
|
||||||
|
const isMatching = filters.every((filter) =>
|
||||||
|
Object.entries(filter).every(([type, value]) => {
|
||||||
|
if (type === "ids") return value.includes(event.id);
|
||||||
|
if (type === "kinds") return value.includes(event.kind);
|
||||||
|
if (type === "authors") return value.includes(event.pubkey);
|
||||||
|
if (type === "since") return event.created_at >= value;
|
||||||
|
if (type === "until") return event.created_at <= value;
|
||||||
|
if (type === "limit") return event.created_at <= value;
|
||||||
|
if (type.startsWith("#")) {
|
||||||
|
const tagName = type.slice(1);
|
||||||
|
return event.tags.some(
|
||||||
|
(tag: string[]) => tag[0] === tagName && value.includes(tag[1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (isMatching) matching.push(subscription);
|
||||||
|
}
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequest(connection: UserConnection, request: NostrClientREQ) {
|
||||||
|
const [, subscriptionId, ...filters] = request;
|
||||||
|
if (connection.subscriptions.has(subscriptionId)) {
|
||||||
|
return log.warn("Duplicate subscription ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`New subscription: ${subscriptionId} with filters: ${
|
||||||
|
JSON.stringify(
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = sqlPartial`SELECT * FROM events`;
|
||||||
|
|
||||||
|
const filtersAreNotEmpty = filters.some((filter) => {
|
||||||
|
return Object.values(filter).some((value) => {
|
||||||
|
return value.length > 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtersAreNotEmpty) {
|
||||||
|
query = mixQuery(query, sqlPartial`WHERE`);
|
||||||
|
|
||||||
|
for (let i = 0; i < filters.length; i++) {
|
||||||
|
// filters act as OR, filter groups act as AND
|
||||||
|
query = mixQuery(query, sqlPartial`(`);
|
||||||
|
|
||||||
|
const filter = Object.entries(filters[i]).filter(([type, value]) => {
|
||||||
|
if (type === "ids") return value.length > 0;
|
||||||
|
if (type === "authors") return value.length > 0;
|
||||||
|
if (type === "kinds") return value.length > 0;
|
||||||
|
if (type.startsWith("#")) return value.length > 0;
|
||||||
|
if (type === "since") return value > 0;
|
||||||
|
if (type === "until") return value > 0;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let j = 0; j < filter.length; j++) {
|
||||||
|
const [type, value] = filter[j];
|
||||||
|
|
||||||
|
if (type === "ids") {
|
||||||
|
const uniqueIds = [...new Set(value)];
|
||||||
|
query = mixQuery(query, sqlPartial`id IN (`);
|
||||||
|
for (let k = 0; k < uniqueIds.length; k++) {
|
||||||
|
const id = uniqueIds[k] as string;
|
||||||
|
|
||||||
|
query = mixQuery(query, sqlPartial`${id}`);
|
||||||
|
|
||||||
|
if (k < uniqueIds.length - 1) {
|
||||||
|
query = mixQuery(query, sqlPartial`,`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query = mixQuery(query, sqlPartial`)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "authors") {
|
||||||
|
const uniqueAuthors = [...new Set(value)];
|
||||||
|
query = mixQuery(query, sqlPartial`pubkey IN (`);
|
||||||
|
for (let k = 0; k < uniqueAuthors.length; k++) {
|
||||||
|
const author = uniqueAuthors[k] as string;
|
||||||
|
|
||||||
|
query = mixQuery(query, sqlPartial`${author}`);
|
||||||
|
|
||||||
|
if (k < uniqueAuthors.length - 1) {
|
||||||
|
query = mixQuery(query, sqlPartial`,`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query = mixQuery(query, sqlPartial`)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "kinds") {
|
||||||
|
const uniqueKinds = [...new Set(value)];
|
||||||
|
query = mixQuery(query, sqlPartial`kind IN (`);
|
||||||
|
for (let k = 0; k < uniqueKinds.length; k++) {
|
||||||
|
const kind = uniqueKinds[k] as number;
|
||||||
|
|
||||||
|
query = mixQuery(query, sqlPartial`${kind}`);
|
||||||
|
|
||||||
|
if (k < uniqueKinds.length - 1) {
|
||||||
|
query = mixQuery(query, sqlPartial`,`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query = mixQuery(query, sqlPartial`)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.startsWith("#")) {
|
||||||
|
const tag = type.slice(1);
|
||||||
|
const uniqueValues = [...new Set(value)];
|
||||||
|
query = mixQuery(query, sqlPartial`(`);
|
||||||
|
for (let k = 0; k < uniqueValues.length; k++) {
|
||||||
|
const value = uniqueValues[k] as string;
|
||||||
|
|
||||||
|
query = mixQuery(
|
||||||
|
query,
|
||||||
|
sqlPartial`id IN (
|
||||||
|
SELECT t.event_id
|
||||||
|
FROM event_tags t
|
||||||
|
WHERE t.tag_name = ${tag}
|
||||||
|
AND t.tag_id IN (
|
||||||
|
SELECT v.tag_id
|
||||||
|
FROM event_tags_values v
|
||||||
|
WHERE v.value_position = 1
|
||||||
|
AND v.value = ${value}
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
if (k < uniqueValues.length - 1) {
|
||||||
|
query = mixQuery(query, sqlPartial`OR`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query = mixQuery(query, sqlPartial`)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "since") {
|
||||||
|
query = mixQuery(query, sqlPartial`created_at >= ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "until") {
|
||||||
|
query = mixQuery(query, sqlPartial`created_at <= ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (j < filter.length - 1) query = mixQuery(query, sqlPartial`AND`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = mixQuery(query, sqlPartial`)`);
|
||||||
|
|
||||||
|
if (i < filters.length - 1) query = mixQuery(query, sqlPartial`OR`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query = mixQuery(query, sqlPartial`ORDER BY created_at ASC`);
|
||||||
|
|
||||||
|
log.debug(query.query, ...query.values);
|
||||||
|
|
||||||
|
const events = connection.db.prepare(query.query).all(...query.values);
|
||||||
|
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
const rawTags = sql`SELECT * FROM event_tags_view WHERE event_id = ${
|
||||||
|
events[i].id
|
||||||
|
}`(connection.db);
|
||||||
|
const tags: { [key: string]: string[] } = {};
|
||||||
|
for (const item of rawTags) {
|
||||||
|
if (!tags[item.tag_name]) tags[item.tag_name] = [item.tag_name];
|
||||||
|
tags[item.tag_name].push(item.tag_value);
|
||||||
|
}
|
||||||
|
const tagsArray = Object.values(tags);
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
id: events[i].id,
|
||||||
|
pubkey: events[i].pubkey,
|
||||||
|
created_at: events[i].created_at,
|
||||||
|
kind: events[i].kind,
|
||||||
|
tags: tagsArray,
|
||||||
|
content: events[i].content,
|
||||||
|
sig: events[i].sig,
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.socket.send(JSON.stringify(["EVENT", subscriptionId, event]));
|
||||||
|
}
|
||||||
|
connection.socket.send(JSON.stringify(["EOSE", subscriptionId]));
|
||||||
|
|
||||||
|
connection.subscriptions.set(subscriptionId, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEvent(
|
||||||
|
connection: UserConnection,
|
||||||
|
event: nostrTools.Event,
|
||||||
|
) {
|
||||||
|
const valid = nostrTools.verifyEvent(event);
|
||||||
|
if (!valid) {
|
||||||
|
connection.socket.send(JSON.stringify(["NOTICE", "Invalid event"]));
|
||||||
|
return log.warn("Invalid event");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedEvent = await createEncryptedEvent(event);
|
||||||
|
try {
|
||||||
|
addEventToDb(event, encryptedEvent);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof EventAlreadyExistsException) {
|
||||||
|
log.warn("Event already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.any(pool.publish(relays, encryptedEvent));
|
||||||
|
|
||||||
|
connection.socket.send(JSON.stringify(["OK", event.id, true, "Event added"]));
|
||||||
|
|
||||||
|
const filtersThatMatchEvent = filtersMatchingEvent(event, connection);
|
||||||
|
|
||||||
|
for (let i = 0; i < filtersThatMatchEvent.length; i++) {
|
||||||
|
const filter = filtersThatMatchEvent[i];
|
||||||
|
connection.socket.send(JSON.stringify(["EVENT", filter, event]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(connection: UserConnection, subscriptionId: string) {
|
||||||
|
if (!connection.subscriptions.has(subscriptionId)) {
|
||||||
|
return log.warn(
|
||||||
|
`Closing unknown subscription? That's weird. Subscription ID: ${subscriptionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.subscriptions.delete(subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve({
|
||||||
|
port: 6942,
|
||||||
|
handler: (request) => {
|
||||||
|
if (request.headers.get("upgrade") === "websocket") {
|
||||||
|
if (!isLocalhost(request)) {
|
||||||
|
return new Response(
|
||||||
|
"Forbidden. Please read the Arx-CCN documentation for more information on how to interact with the relay.",
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
|
||||||
|
const connection = new UserConnection(socket, new Map(), db);
|
||||||
|
|
||||||
|
socket.onopen = () => log.info("User connected");
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
log.debug(`Received: ${event.data}`);
|
||||||
|
if (typeof event.data !== "string" || !isValidJSON(event.data)) {
|
||||||
|
return log.warn("Invalid request");
|
||||||
|
}
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (!isArray(data)) return log.warn("Invalid request");
|
||||||
|
|
||||||
|
const msg = n.clientMsg().parse(data);
|
||||||
|
switch (msg[0]) {
|
||||||
|
case "REQ":
|
||||||
|
return handleRequest(connection, n.clientREQ().parse(data));
|
||||||
|
case "EVENT":
|
||||||
|
return handleEvent(connection, n.clientEVENT().parse(data)[1]);
|
||||||
|
case "CLOSE":
|
||||||
|
return handleClose(connection, n.clientCLOSE().parse(data)[1]);
|
||||||
|
default:
|
||||||
|
return log.warn("Invalid request");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = () => log.info("User disconnected");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return new Response("Eve Relay");
|
||||||
|
},
|
||||||
|
});
|
12
migrations/0-init.sql
Normal file
12
migrations/0-init.sql
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
CREATE TABLE migration_history (
|
||||||
|
migration_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
migration_version INTEGER NOT NULL,
|
||||||
|
migration_name TEXT NOT NULL,
|
||||||
|
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'reverted')),
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_migration_history_name ON migration_history(migration_name);
|
||||||
|
CREATE INDEX idx_migration_history_executed_at ON migration_history(executed_at);
|
36
migrations/1-createEventsStore.sql
Normal file
36
migrations/1-createEventsStore.sql
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
CREATE TABLE events (
|
||||||
|
id TEXT NOT NULL, -- Event ID (32-byte hex)
|
||||||
|
original_id TEXT, -- Original (encrypted) event ID (32-byte hex)
|
||||||
|
pubkey TEXT NOT NULL, -- Author's public key (32-byte hex)
|
||||||
|
created_at INTEGER NOT NULL,-- Unix timestamp in seconds
|
||||||
|
kind INTEGER NOT NULL, -- Event kind number
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
sig TEXT NOT NULL, -- Event signature (64-byte hex)
|
||||||
|
first_seen INTEGER,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE event_tags (
|
||||||
|
tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
tag_name TEXT NOT NULL,
|
||||||
|
tag_index INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE event_tags_values (
|
||||||
|
tag_id INTEGER NOT NULL,
|
||||||
|
value_position INTEGER NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES event_tags(tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_events_id ON events(id);
|
||||||
|
CREATE INDEX idx_events_pubkey ON events(pubkey);
|
||||||
|
CREATE INDEX idx_events_created_at ON events(created_at);
|
||||||
|
CREATE INDEX idx_events_kind ON events(kind);
|
||||||
|
CREATE INDEX idx_events_original_id ON events(original_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_event_tags_event_id ON event_tags(event_id);
|
||||||
|
CREATE INDEX idx_event_tags_name ON event_tags(tag_name, tag_index);
|
||||||
|
CREATE INDEX idx_event_tags_values ON event_tags_values(tag_id, value_position, value);
|
10
migrations/2-createEventsTagsView.sql
Normal file
10
migrations/2-createEventsTagsView.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
CREATE VIEW event_tags_view AS
|
||||||
|
SELECT
|
||||||
|
t.event_id,
|
||||||
|
t.tag_index,
|
||||||
|
t.tag_name,
|
||||||
|
v.value as tag_value,
|
||||||
|
v.value_position as tag_value_position
|
||||||
|
FROM event_tags t
|
||||||
|
LEFT JOIN event_tags_values v ON t.tag_id = v.tag_id
|
||||||
|
ORDER BY t.tag_index, v.value_position;
|
5
tsconfig.json
Normal file
5
tsconfig.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": true
|
||||||
|
}
|
||||||
|
}
|
65
utils.ts
Normal file
65
utils.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { exists } from "jsr:@std/fs";
|
||||||
|
import * as nostrTools from "@nostr/tools";
|
||||||
|
import * as nip06 from "@nostr/tools/nip06";
|
||||||
|
import { decodeBase64, encodeBase64 } from "jsr:@std/encoding@0.224/base64";
|
||||||
|
import { getEveFilePath } from "./utils/files.ts";
|
||||||
|
import {
|
||||||
|
decryptUint8Array,
|
||||||
|
encryptionKey,
|
||||||
|
encryptUint8Array,
|
||||||
|
} from "./utils/encryption.ts";
|
||||||
|
|
||||||
|
export function isLocalhost(req: Request): boolean {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
return (
|
||||||
|
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidJSON(str: string) {
|
||||||
|
try {
|
||||||
|
JSON.parse(str);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArray<T>(obj: unknown): obj is T[] {
|
||||||
|
return Array.isArray(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomTimeUpTo2DaysInThePast() {
|
||||||
|
const now = Date.now();
|
||||||
|
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000 - 3600 * 1000; // 1 hour buffer in case of clock skew
|
||||||
|
return Math.floor(
|
||||||
|
(Math.floor(Math.random() * (now - twoDaysAgo)) + twoDaysAgo) / 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCCNPubkey(): Promise<string> {
|
||||||
|
const ccnPubPath = await getEveFilePath("ccn.pub");
|
||||||
|
const doWeHaveKey = await exists(ccnPubPath);
|
||||||
|
if (doWeHaveKey) return Deno.readTextFileSync(ccnPubPath);
|
||||||
|
const ccnSeed = Deno.env.get("CCN_SEED") || nip06.generateSeedWords();
|
||||||
|
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
|
||||||
|
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey);
|
||||||
|
const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
|
||||||
|
|
||||||
|
Deno.writeTextFileSync(ccnPubPath, ccnPublicKey);
|
||||||
|
Deno.writeTextFileSync(
|
||||||
|
await getEveFilePath("ccn.priv"),
|
||||||
|
encodeBase64(encryptedPrivateKey),
|
||||||
|
);
|
||||||
|
Deno.writeTextFileSync(await getEveFilePath("ccn.seed"), ccnSeed);
|
||||||
|
|
||||||
|
return ccnPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCCNPrivateKey(): Promise<Uint8Array> {
|
||||||
|
const encryptedPrivateKey = Deno.readTextFileSync(
|
||||||
|
await getEveFilePath("ccn.priv"),
|
||||||
|
);
|
||||||
|
return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey);
|
||||||
|
}
|
33
utils/encryption.ts
Normal file
33
utils/encryption.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
||||||
|
import { managedNonce } from "@noble/ciphers/webcrypto";
|
||||||
|
import { decodeBase64 } from "jsr:@std/encoding/base64";
|
||||||
|
export const encryptionKey = decodeBase64(Deno.env.get("ENCRYPTION_KEY") || "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||||
|
*
|
||||||
|
* @param data - The data to be encrypted as a Uint8Array.
|
||||||
|
* @param key - The encryption key as a Uint8Array.
|
||||||
|
* @returns The encrypted data as a Uint8Array.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function encryptUint8Array(
|
||||||
|
data: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
return managedNonce(xchacha20poly1305)(key).encrypt(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||||
|
*
|
||||||
|
* @param data - The data to be decrypted as a Uint8Array.
|
||||||
|
* @param key - The decryption key as a Uint8Array.
|
||||||
|
* @returns The decrypted data as a Uint8Array.
|
||||||
|
*/
|
||||||
|
export function decryptUint8Array(
|
||||||
|
data: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
return managedNonce(xchacha20poly1305)(key).decrypt(data);
|
||||||
|
}
|
31
utils/files.ts
Normal file
31
utils/files.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { exists } from "jsr:@std/fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the path to Eve's configuration directory.
|
||||||
|
*
|
||||||
|
* The configuration directory is resolved in the following order:
|
||||||
|
* 1. The value of the `XDG_CONFIG_HOME` environment variable.
|
||||||
|
* 2. The value of the `HOME` environment variable, with `.config` appended.
|
||||||
|
*
|
||||||
|
* If the resolved path does not exist, create it.
|
||||||
|
*/
|
||||||
|
export async function getEveConfigHome(): Promise<string> {
|
||||||
|
const xdgConfigHome = Deno.env.get("XDG_CONFIG_HOME") ??
|
||||||
|
`${Deno.env.get("HOME")}/.config`;
|
||||||
|
const storagePath = `${xdgConfigHome}/arx/Eve`;
|
||||||
|
if (!(await exists(storagePath))) {
|
||||||
|
await Deno.mkdir(storagePath, { recursive: true });
|
||||||
|
}
|
||||||
|
return storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the path to the file in Eve's configuration directory.
|
||||||
|
*
|
||||||
|
* @param file The name of the file to return the path for.
|
||||||
|
* @returns The path to the file in Eve's configuration directory.
|
||||||
|
*/
|
||||||
|
export async function getEveFilePath(file: string): Promise<string> {
|
||||||
|
const storagePath = await getEveConfigHome();
|
||||||
|
return `${storagePath}/${file}`;
|
||||||
|
}
|
75
utils/logs.ts
Normal file
75
utils/logs.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import * as colors from "jsr:@std/fmt@^1.0.4/colors";
|
||||||
|
import * as log from "jsr:@std/log";
|
||||||
|
import { getEveFilePath } from "./files.ts";
|
||||||
|
export * as log from "jsr:@std/log";
|
||||||
|
|
||||||
|
export async function setupLogger() {
|
||||||
|
const formatLevel = (level: number): string => {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
10: colors.gray("[DEBUG]"),
|
||||||
|
20: colors.green("[INFO] "),
|
||||||
|
30: colors.yellow("[WARN] "),
|
||||||
|
40: colors.red("[ERROR]"),
|
||||||
|
50: colors.bgRed("[FATAL]"),
|
||||||
|
}[level] || `[LVL${level}]`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelName = (level: number): string => {
|
||||||
|
return {
|
||||||
|
10: "DEBUG",
|
||||||
|
20: "INFO",
|
||||||
|
30: "WARN",
|
||||||
|
40: "ERROR",
|
||||||
|
50: "FATAL",
|
||||||
|
}[level] || `LVL${level}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatArg = (arg: unknown): string => {
|
||||||
|
if (typeof arg === "object") return JSON.stringify(arg);
|
||||||
|
return String(arg);
|
||||||
|
};
|
||||||
|
|
||||||
|
await log.setup({
|
||||||
|
handlers: {
|
||||||
|
console: new log.ConsoleHandler("DEBUG", {
|
||||||
|
useColors: true,
|
||||||
|
formatter: (record) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
let msg = `${colors.dim(`[${timestamp}]`)} ${
|
||||||
|
formatLevel(record.level)
|
||||||
|
} ${record.msg}`;
|
||||||
|
|
||||||
|
if (record.args.length > 0) {
|
||||||
|
const args = record.args
|
||||||
|
.map((arg, i) => `${colors.dim(`arg${i}:`)} ${formatArg(arg)}`)
|
||||||
|
.join(" ");
|
||||||
|
msg += ` ${colors.dim("|")} ${args}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
file: new log.FileHandler("DEBUG", {
|
||||||
|
filename: Deno.env.get("LOG_FILE") ||
|
||||||
|
await getEveFilePath("eve-logs.jsonl"),
|
||||||
|
formatter: (record) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return JSON.stringify({
|
||||||
|
timestamp,
|
||||||
|
level: levelName(record.level),
|
||||||
|
msg: record.msg,
|
||||||
|
args: record.args,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
loggers: {
|
||||||
|
default: {
|
||||||
|
level: "DEBUG",
|
||||||
|
handlers: ["console", "file"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
99
utils/queries.ts
Normal file
99
utils/queries.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import type { BindValue, Database } from "@db/sqlite";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a SQL query with placeholders for values.
|
||||||
|
*
|
||||||
|
* This function takes a template string and interpolates it with the given
|
||||||
|
* values, replacing placeholders with `?`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const query = sqlPartial`SELECT * FROM events WHERE id = ? OR id = ?`,
|
||||||
|
* ['1', '2'];
|
||||||
|
* // query = {
|
||||||
|
* // query: 'SELECT * FROM events WHERE id = ? OR id = ?',
|
||||||
|
* // values: ['1', '2']
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* @param {TemplateStringsArray} segments A template string
|
||||||
|
* @param {...BindValue[]} values Values to interpolate
|
||||||
|
* @returns {{ query: string, values: BindValue[] }} A SQL query with placeholders
|
||||||
|
*/
|
||||||
|
export function sqlPartial(
|
||||||
|
segments: TemplateStringsArray,
|
||||||
|
...values: BindValue[]
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
query: segments.reduce(
|
||||||
|
(acc, str, i) => acc + str + (i < values.length ? "?" : ""),
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
values: values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a SQL query with placeholders for values and return a function
|
||||||
|
* that executes that query on a database.
|
||||||
|
*
|
||||||
|
* This is a convenience wrapper around `sqlPartial` and `sqlPartialRunner`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const run = sql`SELECT * FROM events WHERE id = ? OR id = ?`,
|
||||||
|
* ['1', '2'];
|
||||||
|
* const results = run(db);
|
||||||
|
*
|
||||||
|
* @param {TemplateStringsArray} segments A template string
|
||||||
|
* @param {...BindValue[]} values Values to interpolate
|
||||||
|
* @returns {Function} A function that takes a Database and returns the query results
|
||||||
|
*/
|
||||||
|
export function sql(segments: TemplateStringsArray, ...values: BindValue[]) {
|
||||||
|
return sqlPartialRunner(sqlPartial(segments, ...values));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine multiple partial queries into a single query.
|
||||||
|
*
|
||||||
|
* This function takes any number of partial queries with values and combines
|
||||||
|
* them into a single query.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const query1 = { query: 'SELECT * FROM foo', values: [] };
|
||||||
|
* const query2 = { query: 'WHERE bar = ?', values: ['5'] };
|
||||||
|
* const query = mixQuery(query1, query2);
|
||||||
|
* // query = {
|
||||||
|
* // query: 'SELECT * FROM foo WHERE bar = ?',
|
||||||
|
* // values: ['5']
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* @param {...{ query: string, values: BindValue[] }} queries Partial queries
|
||||||
|
* @returns {{ query: string, values: BindValue[] }} A combined query
|
||||||
|
*/
|
||||||
|
export function mixQuery(...queries: { query: string; values: BindValue[] }[]) {
|
||||||
|
const { query, values } = queries.reduce(
|
||||||
|
(acc, { query, values }) => ({
|
||||||
|
query: `${acc.query} ${query}`,
|
||||||
|
values: [...acc.values, ...values],
|
||||||
|
}),
|
||||||
|
{ query: "", values: [] },
|
||||||
|
);
|
||||||
|
return { query, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a SQL query against a database.
|
||||||
|
*
|
||||||
|
* This function takes a query object containing a SQL query string with placeholders
|
||||||
|
* and an array of values. It returns a function that, when given a Database instance,
|
||||||
|
* prepares the query and executes it, returning all results.
|
||||||
|
*
|
||||||
|
* @param {Object} query An object containing the SQL query string and corresponding values
|
||||||
|
* @returns {Function} A function that takes a Database instance and returns the query results
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function sqlPartialRunner(query: {
|
||||||
|
query: string;
|
||||||
|
values: BindValue[];
|
||||||
|
}) {
|
||||||
|
const run = (db: Database) => db.prepare(query.query).all(...query.values);
|
||||||
|
return run;
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue