mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge remote-tracking branch 'origin/main' into replace-relaypool-npool
This commit is contained in:
commit
7ad33ae59c
57 changed files with 1805 additions and 644 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
image: denoland/deno:1.43.3
|
image: denoland/deno:1.44.2
|
||||||
|
|
||||||
default:
|
default:
|
||||||
interruptible: true
|
interruptible: true
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
deno 1.43.3
|
deno 1.44.2
|
||||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-06-14
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
|
||||||
|
[unreleased]: https://gitlab.com/soapbox-pub/ditto/-/compare/v1.0.0...HEAD
|
||||||
|
[1.0.0]: https://gitlab.com/soapbox-pub/ditto/-/tags/v1.0.0
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM denoland/deno:1.43.3
|
FROM denoland/deno:1.44.2
|
||||||
EXPOSE 4036
|
EXPOSE 4036
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN mkdir -p data && chown -R deno data
|
RUN mkdir -p data && chown -R deno data
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ For more info see: https://docs.soapbox.pub/ditto/
|
||||||
|
|
||||||
<img width="400" src="ditto-planet.png">
|
<img width="400" src="ditto-planet.png">
|
||||||
|
|
||||||
⚠️ This software is a work in progress.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- [x] Built-in Nostr relay
|
- [x] Built-in Nostr relay
|
||||||
|
|
@ -18,9 +16,9 @@ For more info see: https://docs.soapbox.pub/ditto/
|
||||||
- [x] Reposts
|
- [x] Reposts
|
||||||
- [x] Notifications
|
- [x] Notifications
|
||||||
- [x] Profiles
|
- [x] Profiles
|
||||||
- [ ] Search
|
- [x] Search
|
||||||
- [x] Moderation
|
- [x] Moderation
|
||||||
- [ ] Zaps
|
- [x] Zaps
|
||||||
- [x] Customizable
|
- [x] Customizable
|
||||||
- [x] Open source
|
- [x] Open source
|
||||||
- [x] Self-hosted
|
- [x] Self-hosted
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@
|
||||||
"nsec": "deno run scripts/nsec.ts",
|
"nsec": "deno run scripts/nsec.ts",
|
||||||
"admin:event": "deno run -A scripts/admin-event.ts",
|
"admin:event": "deno run -A scripts/admin-event.ts",
|
||||||
"admin:role": "deno run -A scripts/admin-role.ts",
|
"admin:role": "deno run -A scripts/admin-role.ts",
|
||||||
|
"setup": "deno run -A scripts/setup.ts",
|
||||||
"stats:recompute": "deno run -A scripts/stats-recompute.ts",
|
"stats:recompute": "deno run -A scripts/stats-recompute.ts",
|
||||||
"soapbox": "curl -O https://dl.soapbox.pub/main/soapbox.zip && mkdir -p public && mv soapbox.zip public/ && cd public/ && unzip soapbox.zip && rm soapbox.zip"
|
"soapbox": "curl -O https://dl.soapbox.pub/main/soapbox.zip && mkdir -p public && mv soapbox.zip public/ && cd public/ && unzip soapbox.zip && rm soapbox.zip",
|
||||||
|
"trends": "deno run -A scripts/trends.ts"
|
||||||
},
|
},
|
||||||
"unstable": ["cron", "ffi", "kv", "worker-options"],
|
"unstable": ["cron", "ffi", "kv", "worker-options"],
|
||||||
"exclude": ["./public"],
|
"exclude": ["./public"],
|
||||||
|
|
@ -22,7 +24,7 @@
|
||||||
"@db/sqlite": "jsr:@db/sqlite@^0.11.1",
|
"@db/sqlite": "jsr:@db/sqlite@^0.11.1",
|
||||||
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
||||||
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
|
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
|
||||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.22.5",
|
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.23.2",
|
||||||
"@scure/base": "npm:@scure/base@^1.1.6",
|
"@scure/base": "npm:@scure/base@^1.1.6",
|
||||||
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
|
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
|
||||||
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||||
|
|
@ -32,6 +34,7 @@
|
||||||
"@std/crypto": "jsr:@std/crypto@^0.224.0",
|
"@std/crypto": "jsr:@std/crypto@^0.224.0",
|
||||||
"@std/dotenv": "jsr:@std/dotenv@^0.224.0",
|
"@std/dotenv": "jsr:@std/dotenv@^0.224.0",
|
||||||
"@std/encoding": "jsr:@std/encoding@^0.224.0",
|
"@std/encoding": "jsr:@std/encoding@^0.224.0",
|
||||||
|
"@std/fs": "jsr:@std/fs@^0.229.3",
|
||||||
"@std/json": "jsr:@std/json@^0.223.0",
|
"@std/json": "jsr:@std/json@^0.223.0",
|
||||||
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
||||||
"@std/streams": "jsr:@std/streams@^0.223.0",
|
"@std/streams": "jsr:@std/streams@^0.223.0",
|
||||||
|
|
@ -53,6 +56,7 @@
|
||||||
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
|
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
|
||||||
"nostr-tools": "npm:nostr-tools@2.5.1",
|
"nostr-tools": "npm:nostr-tools@2.5.1",
|
||||||
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
|
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
|
||||||
|
"question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts",
|
||||||
"tldts": "npm:tldts@^6.0.14",
|
"tldts": "npm:tldts@^6.0.14",
|
||||||
"tseep": "npm:tseep@^1.2.1",
|
"tseep": "npm:tseep@^1.2.1",
|
||||||
"type-fest": "npm:type-fest@^4.3.0",
|
"type-fest": "npm:type-fest@^4.3.0",
|
||||||
|
|
|
||||||
47
deno.lock
generated
47
deno.lock
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5",
|
"jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5",
|
||||||
"jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4",
|
"jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4",
|
||||||
"jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5",
|
"jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5",
|
||||||
|
"jsr:@nostrify/nostrify@^0.23.2": "jsr:@nostrify/nostrify@0.23.2",
|
||||||
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0",
|
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0",
|
||||||
"jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0",
|
"jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0",
|
||||||
"jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0",
|
"jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0",
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0",
|
"jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0",
|
||||||
"jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3",
|
"jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3",
|
||||||
"jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0",
|
"jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0",
|
||||||
|
"jsr:@std/bytes@^1.0.0-rc.3": "jsr:@std/bytes@1.0.0",
|
||||||
"jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0",
|
"jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0",
|
||||||
"jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.0",
|
"jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.0",
|
||||||
"jsr:@std/encoding@^0.221.0": "jsr:@std/encoding@0.221.0",
|
"jsr:@std/encoding@^0.221.0": "jsr:@std/encoding@0.221.0",
|
||||||
|
|
@ -24,8 +26,9 @@
|
||||||
"jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3",
|
"jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3",
|
||||||
"jsr:@std/fmt@^0.221.0": "jsr:@std/fmt@0.221.0",
|
"jsr:@std/fmt@^0.221.0": "jsr:@std/fmt@0.221.0",
|
||||||
"jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0",
|
"jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0",
|
||||||
|
"jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3",
|
||||||
"jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.0",
|
"jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.0",
|
||||||
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.0",
|
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.1",
|
||||||
"jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1",
|
"jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1",
|
||||||
"jsr:@std/path@0.217": "jsr:@std/path@0.217.0",
|
"jsr:@std/path@0.217": "jsr:@std/path@0.217.0",
|
||||||
"jsr:@std/path@^0.221.0": "jsr:@std/path@0.221.0",
|
"jsr:@std/path@^0.221.0": "jsr:@std/path@0.221.0",
|
||||||
|
|
@ -119,6 +122,20 @@
|
||||||
"npm:zod@^3.23.8"
|
"npm:zod@^3.23.8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@nostrify/nostrify@0.23.2": {
|
||||||
|
"integrity": "c880fd91b5fe69a6239f98cae62297ffccc2a78d160af4d376dd05899352daf0",
|
||||||
|
"dependencies": [
|
||||||
|
"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:kysely@^0.27.3",
|
||||||
|
"npm:lru-cache@^10.2.0",
|
||||||
|
"npm:nostr-tools@^2.7.0",
|
||||||
|
"npm:websocket-ts@^2.1.5",
|
||||||
|
"npm:zod@^3.23.8"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@soapbox/kysely-deno-sqlite@2.2.0": {
|
"@soapbox/kysely-deno-sqlite@2.2.0": {
|
||||||
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
|
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -146,6 +163,9 @@
|
||||||
"@std/bytes@0.224.0": {
|
"@std/bytes@0.224.0": {
|
||||||
"integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49"
|
"integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49"
|
||||||
},
|
},
|
||||||
|
"@std/bytes@1.0.0": {
|
||||||
|
"integrity": "9392e72af80adccaa1197912fa19990ed091cb98d5c9c4344b0c301b22d7c632"
|
||||||
|
},
|
||||||
"@std/crypto@0.224.0": {
|
"@std/crypto@0.224.0": {
|
||||||
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
|
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -172,6 +192,9 @@
|
||||||
"jsr:@std/path@^0.221.0"
|
"jsr:@std/path@^0.221.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@std/fs@0.229.3": {
|
||||||
|
"integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb"
|
||||||
|
},
|
||||||
"@std/internal@1.0.0": {
|
"@std/internal@1.0.0": {
|
||||||
"integrity": "ac6a6dfebf838582c4b4f61a6907374e27e05bedb6ce276e0f1608fe84e7cd9a"
|
"integrity": "ac6a6dfebf838582c4b4f61a6907374e27e05bedb6ce276e0f1608fe84e7cd9a"
|
||||||
},
|
},
|
||||||
|
|
@ -181,6 +204,12 @@
|
||||||
"jsr:@std/bytes@^0.224.0"
|
"jsr:@std/bytes@^0.224.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@std/io@0.224.1": {
|
||||||
|
"integrity": "73de242551a5c0965eb33e36b1fc7df4834ffbc836a1a643a410ccd11253d6be",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/bytes@^1.0.0-rc.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@std/media-types@0.224.1": {
|
"@std/media-types@0.224.1": {
|
||||||
"integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1"
|
"integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1"
|
||||||
},
|
},
|
||||||
|
|
@ -1190,6 +1219,7 @@
|
||||||
"https://deno.land/std@0.214.0/path/windows/separator.ts": "e51c5522140eff4f8402617c5c68a201fdfa3a1a8b28dc23587cff931b665e43",
|
"https://deno.land/std@0.214.0/path/windows/separator.ts": "e51c5522140eff4f8402617c5c68a201fdfa3a1a8b28dc23587cff931b665e43",
|
||||||
"https://deno.land/std@0.214.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484",
|
"https://deno.land/std@0.214.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484",
|
||||||
"https://deno.land/std@0.214.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c",
|
"https://deno.land/std@0.214.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c",
|
||||||
|
"https://deno.land/std@0.80.0/encoding/utf8.ts": "1b7e77db9a12363c67872f8a208886ca1329f160c1ca9133b13d2ed399688b99",
|
||||||
"https://deno.land/x/hono@v3.10.1/adapter/deno/serve-static.ts": "ba10cf6aaf39da942b0d49c3b9877ddba69d41d414c6551d890beb1085f58eea",
|
"https://deno.land/x/hono@v3.10.1/adapter/deno/serve-static.ts": "ba10cf6aaf39da942b0d49c3b9877ddba69d41d414c6551d890beb1085f58eea",
|
||||||
"https://deno.land/x/hono@v3.10.1/client/client.ts": "ff340f58041203879972dd368b011ed130c66914f789826610869a90603406bf",
|
"https://deno.land/x/hono@v3.10.1/client/client.ts": "ff340f58041203879972dd368b011ed130c66914f789826610869a90603406bf",
|
||||||
"https://deno.land/x/hono@v3.10.1/client/index.ts": "3ff4cf246f3543f827a85a2c84d66a025ac350ee927613629bda47e854bfb7ba",
|
"https://deno.land/x/hono@v3.10.1/client/index.ts": "3ff4cf246f3543f827a85a2c84d66a025ac350ee927613629bda47e854bfb7ba",
|
||||||
|
|
@ -1254,6 +1284,8 @@
|
||||||
"https://deno.land/x/hono@v3.10.1/utils/url.ts": "5fc3307ef3cb2e6f34ec2a03e3d7f2126c6a9f5f0eab677222df3f0e40bd7567",
|
"https://deno.land/x/hono@v3.10.1/utils/url.ts": "5fc3307ef3cb2e6f34ec2a03e3d7f2126c6a9f5f0eab677222df3f0e40bd7567",
|
||||||
"https://deno.land/x/hono@v3.10.1/validator/index.ts": "6c986e8b91dcf857ecc8164a506ae8eea8665792a4ff7215471df669c632ae7c",
|
"https://deno.land/x/hono@v3.10.1/validator/index.ts": "6c986e8b91dcf857ecc8164a506ae8eea8665792a4ff7215471df669c632ae7c",
|
||||||
"https://deno.land/x/hono@v3.10.1/validator/validator.ts": "afa5e52495e0996fbba61996736fab5c486590d72d376f809e9f9ff4e0c463e9",
|
"https://deno.land/x/hono@v3.10.1/validator/validator.ts": "afa5e52495e0996fbba61996736fab5c486590d72d376f809e9f9ff4e0c463e9",
|
||||||
|
"https://deno.land/x/keypress@0.0.7/dep.ts": "feeb0056d332c126343249b79fe86cb0bf3abd03ea4c270cd39575c38d37a911",
|
||||||
|
"https://deno.land/x/keypress@0.0.7/mod.ts": "1130570c2397118a3a301b1137400a8e55486716cc3557b3bd5e9947b6b9c035",
|
||||||
"https://deno.land/x/kysely_deno_postgres@v0.4.0/deps.ts": "7970f66a52a9fa0cef607cb7ef0171212af2ccb83e73ecfa7629aabc28a38793",
|
"https://deno.land/x/kysely_deno_postgres@v0.4.0/deps.ts": "7970f66a52a9fa0cef607cb7ef0171212af2ccb83e73ecfa7629aabc28a38793",
|
||||||
"https://deno.land/x/kysely_deno_postgres@v0.4.0/mod.ts": "662438fd3909984bb8cbaf3fd44d2121e949d11301baf21d6c3f057ccf9887de",
|
"https://deno.land/x/kysely_deno_postgres@v0.4.0/mod.ts": "662438fd3909984bb8cbaf3fd44d2121e949d11301baf21d6c3f057ccf9887de",
|
||||||
"https://deno.land/x/kysely_deno_postgres@v0.4.0/src/PostgreSQLDriver.ts": "590c2fa248cff38e6e0f623050983039b5fde61e9c7131593d2922fb1f0eb921",
|
"https://deno.land/x/kysely_deno_postgres@v0.4.0/src/PostgreSQLDriver.ts": "590c2fa248cff38e6e0f623050983039b5fde61e9c7131593d2922fb1f0eb921",
|
||||||
|
|
@ -1312,13 +1344,23 @@
|
||||||
"https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/mod.ts": "662438fd3909984bb8cbaf3fd44d2121e949d11301baf21d6c3f057ccf9887de",
|
"https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/mod.ts": "662438fd3909984bb8cbaf3fd44d2121e949d11301baf21d6c3f057ccf9887de",
|
||||||
"https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/src/PostgreSQLDriver.ts": "590c2fa248cff38e6e0f623050983039b5fde61e9c7131593d2922fb1f0eb921",
|
"https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/src/PostgreSQLDriver.ts": "590c2fa248cff38e6e0f623050983039b5fde61e9c7131593d2922fb1f0eb921",
|
||||||
"https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/src/PostgreSQLDriverDatabaseConnection.ts": "2158de426860bfd4f8e73afff0289bd40a11e273c8d883d4fd6474db01a9c2a7",
|
"https://gitlab.com/soapbox-pub/kysely-deno-postgres/-/raw/main/src/PostgreSQLDriverDatabaseConnection.ts": "2158de426860bfd4f8e73afff0289bd40a11e273c8d883d4fd6474db01a9c2a7",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/KeyCombo.ts": "a370b2dca76faa416d00e45479c8ce344971b5b86b44b4d0b213245c4bd2f8a3",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/checkbox.ts": "e337ee7396aaefe6cc8c6349a445542fe7f0760311773369c9012b3fa278d21e",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/config.ts": "a94a022c757f63ee7c410e29b97d3bfab1811889fb4483f56395cf376a911d1b",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/confirm.ts": "dde395f351e14ebff9dd882c49c1376f0c648a5978d17dfad997f9958df01d1c",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/input.ts": "5b7a3e194e6a5b74bb26d5ef1363d78619769772ad01244fd6f95b9c66cc6f0d",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/list.ts": "3b96403b043b5b5bc417d8d07b34828961c1f9d2bdbc9f24e6ef40fb9c95438e",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts": "cb10c598652cf7edf600af17f73bcadcdedf6900d9f5b5647e89ba2ea378b7d5",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/password.ts": "3c578bd21e4fd283431aa940357f40fed2e26d3de12ad129a696d7fe38ae744d",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/text-util.ts": "37c0437d2030c0b6255f10afec7ccfcb6b195e9a0a011bb7956595142c3d7383",
|
||||||
|
"https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/util.ts": "a8285450db7b56a3e507f478aaad68927ecb1ee545449cb869ccc4aace13fada",
|
||||||
"https://unpkg.com/nostr-relaypool2@0.6.34/lib/nostr-relaypool.worker.js": "a336e5c58b1e6946ae8943eb4fef21b810dc2a5a233438cff92b883673e29c96"
|
"https://unpkg.com/nostr-relaypool2@0.6.34/lib/nostr-relaypool.worker.js": "a336e5c58b1e6946ae8943eb4fef21b810dc2a5a233438cff92b883673e29c96"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||||
"jsr:@db/sqlite@^0.11.1",
|
"jsr:@db/sqlite@^0.11.1",
|
||||||
"jsr:@nostrify/nostrify@^0.22.5",
|
"jsr:@nostrify/nostrify@^0.23.2",
|
||||||
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||||
"jsr:@soapbox/stickynotes@^0.4.0",
|
"jsr:@soapbox/stickynotes@^0.4.0",
|
||||||
"jsr:@std/assert@^0.225.1",
|
"jsr:@std/assert@^0.225.1",
|
||||||
|
|
@ -1326,6 +1368,7 @@
|
||||||
"jsr:@std/crypto@^0.224.0",
|
"jsr:@std/crypto@^0.224.0",
|
||||||
"jsr:@std/dotenv@^0.224.0",
|
"jsr:@std/dotenv@^0.224.0",
|
||||||
"jsr:@std/encoding@^0.224.0",
|
"jsr:@std/encoding@^0.224.0",
|
||||||
|
"jsr:@std/fs@^0.229.3",
|
||||||
"jsr:@std/json@^0.223.0",
|
"jsr:@std/json@^0.223.0",
|
||||||
"jsr:@std/media-types@^0.224.1",
|
"jsr:@std/media-types@^0.224.1",
|
||||||
"jsr:@std/streams@^0.223.0",
|
"jsr:@std/streams@^0.223.0",
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
# Ditto custom events
|
|
||||||
|
|
||||||
Instead of using database tables, the Ditto server publishes Nostr events that describe its state. It then reads these events using Nostr filters.
|
|
||||||
|
|
||||||
## Ditto User (kind 30361)
|
|
||||||
|
|
||||||
The Ditto server publishes kind `30361` events to represent users. These events are parameterized replaceable events of kind `30361` where the `d` tag is a pubkey. These events are published by Ditto's internal admin keypair.
|
|
||||||
|
|
||||||
User events have the following tags:
|
|
||||||
|
|
||||||
- `d` - pubkey of the user.
|
|
||||||
- `role` - one of `admin` or `user`.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59",
|
|
||||||
"kind": 30361,
|
|
||||||
"pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06",
|
|
||||||
"content": "",
|
|
||||||
"created_at": 1691568245,
|
|
||||||
"tags": [
|
|
||||||
["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],
|
|
||||||
["role", "user"],
|
|
||||||
["alt", "User's account was updated by the admins of ditto.ngrok.app"]
|
|
||||||
],
|
|
||||||
"sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## NIP-78
|
|
||||||
|
|
||||||
[NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) defines events of kind `30078` with a globally unique `d` tag. These events are queried by the `d` tag, which allows Ditto to store custom data on relays. Ditto uses reverse DNS names like `pub.ditto.<thing>` for `d` tags.
|
|
||||||
|
|
||||||
The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events.
|
|
||||||
|
|
||||||
### `pub.ditto.pleroma.config`
|
|
||||||
|
|
||||||
NIP-04 encrypted JSON array of Pleroma ConfigDB objects. Pleroma admin API endpoints set this config, and Ditto reads from it.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59",
|
|
||||||
"kind": 30361,
|
|
||||||
"pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06",
|
|
||||||
"content": "",
|
|
||||||
"created_at": 1691568245,
|
|
||||||
"tags": [
|
|
||||||
["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],
|
|
||||||
["name", "alex"],
|
|
||||||
["role", "user"],
|
|
||||||
["origin", "https://ditto.ngrok.app"],
|
|
||||||
["alt", "@alex@ditto.ngrok.app's account was updated by the admins of ditto.ngrok.app"]
|
|
||||||
],
|
|
||||||
"sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507"
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Nginx configuration for Ditto with IPFS.
|
# Nginx configuration for Ditto.
|
||||||
#
|
#
|
||||||
# Edit this file to change occurences of "example.com" to your own domain.
|
# Edit this file to change occurences of "example.com" to your own domain.
|
||||||
|
|
||||||
|
|
@ -6,10 +6,6 @@ upstream ditto {
|
||||||
server 127.0.0.1:4036;
|
server 127.0.0.1:4036;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream ipfs_gateway {
|
|
||||||
server 127.0.0.1:8080;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
|
|
@ -18,21 +14,8 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
# Uncomment these lines once you acquire a certificate:
|
|
||||||
# listen 443 ssl http2;
|
|
||||||
# listen [::]:443 ssl http2;
|
|
||||||
server_name example.com;
|
server_name example.com;
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_tickets off;
|
|
||||||
|
|
||||||
# Uncomment these lines once you acquire a certificate:
|
|
||||||
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
|
||||||
# ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
|
||||||
|
|
||||||
keepalive_timeout 70;
|
keepalive_timeout 70;
|
||||||
sendfile on;
|
sendfile on;
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
|
|
@ -44,53 +27,42 @@ server {
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
root /opt/ditto/public;
|
||||||
|
|
||||||
|
location @spa {
|
||||||
|
try_files /index.html /dev/null;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @frontend {
|
||||||
|
try_files $uri @ditto-static;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @ditto-static {
|
||||||
|
root /opt/ditto/static;
|
||||||
|
try_files $uri @spa;
|
||||||
|
}
|
||||||
|
|
||||||
location /packs {
|
location /packs {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
root /opt/ditto/public;
|
root /opt/ditto/public;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(instance|sw.js$|sw.js.map$) {
|
location ~ ^/(instance|sw\.js$|sw\.js\.map$) {
|
||||||
root /opt/ditto/public;
|
root /opt/ditto/public;
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /images {
|
location = /favicon.ico {
|
||||||
root /opt/ditto/static;
|
root /opt/ditto/static;
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location ~ ^/(api|relay|oauth|manifest.json|nodeinfo|.well-known/(nodeinfo|nostr.json)) {
|
||||||
proxy_pass http://ditto;
|
proxy_pass http://ditto;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
# Uncomment these lines once you acquire a certificate:
|
|
||||||
# listen 443 ssl http2;
|
|
||||||
# listen [::]:443 ssl http2;
|
|
||||||
server_name media.example.com;
|
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_tickets off;
|
|
||||||
|
|
||||||
# Uncomment these lines once you acquire a certificate:
|
|
||||||
# ssl_certificate /etc/letsencrypt/live/media.example.com/fullchain.pem;
|
|
||||||
# ssl_certificate_key /etc/letsencrypt/live/media.example.com/privkey.pem;
|
|
||||||
|
|
||||||
keepalive_timeout 70;
|
|
||||||
sendfile on;
|
|
||||||
client_max_body_size 1m;
|
|
||||||
ignore_invalid_headers off;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://ipfs_gateway;
|
try_files /dev/null @frontend;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=IPFS Daemon
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=ditto
|
|
||||||
ExecStart=/usr/local/bin/ipfs daemon
|
|
||||||
Restart=on-failure
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NSchema } from '@nostrify/nostrify';
|
import { NSchema } from '@nostrify/nostrify';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { DittoDB } from '@/db/DittoDB.ts';
|
import { DittoDB } from '@/db/DittoDB.ts';
|
||||||
import { Conf } from '@/config.ts';
|
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
|
|
@ -9,7 +9,8 @@ import { nostrNow } from '@/utils.ts';
|
||||||
const kysely = await DittoDB.getInstance();
|
const kysely = await DittoDB.getInstance();
|
||||||
const eventsDB = new EventsDB(kysely);
|
const eventsDB = new EventsDB(kysely);
|
||||||
|
|
||||||
const [pubkey, role] = Deno.args;
|
const [pubkeyOrNpub, role] = Deno.args;
|
||||||
|
const pubkey = pubkeyOrNpub.startsWith('npub1') ? nip19.decode(pubkeyOrNpub as `npub1${string}`).data : pubkeyOrNpub;
|
||||||
|
|
||||||
if (!NSchema.id().safeParse(pubkey).success) {
|
if (!NSchema.id().safeParse(pubkey).success) {
|
||||||
console.error('Invalid pubkey');
|
console.error('Invalid pubkey');
|
||||||
|
|
@ -21,14 +22,39 @@ if (!['admin', 'user'].includes(role)) {
|
||||||
Deno.exit(1);
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await new AdminSigner().signEvent({
|
const signer = new AdminSigner();
|
||||||
kind: 30361,
|
const admin = await signer.getPublicKey();
|
||||||
tags: [
|
|
||||||
|
const [existing] = await eventsDB.query([{
|
||||||
|
kinds: [30382],
|
||||||
|
authors: [admin],
|
||||||
|
'#d': [pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const prevTags = (existing?.tags ?? []).filter(([name, value]) => {
|
||||||
|
if (name === 'd') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name === 'n' && value === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
['d', pubkey],
|
['d', pubkey],
|
||||||
['role', role],
|
];
|
||||||
// NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
|
|
||||||
['alt', `User's account was updated by the admins of ${Conf.url.host}`],
|
if (role === 'admin') {
|
||||||
],
|
tags.push(['n', 'admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(...prevTags);
|
||||||
|
|
||||||
|
const event = await signer.signEvent({
|
||||||
|
kind: 30382,
|
||||||
|
tags,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: nostrNow(),
|
created_at: nostrNow(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
109
scripts/setup.ts
Normal file
109
scripts/setup.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { exists } from '@std/fs/exists';
|
||||||
|
import { generateSecretKey, nip19 } from 'nostr-tools';
|
||||||
|
import question from 'question-deno';
|
||||||
|
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Hello! Welcome to the Ditto setup tool. We will ask you a few questions to generate a .env file for you.');
|
||||||
|
console.log('');
|
||||||
|
console.log('- Ditto docs: https://docs.soapbox.pub/ditto/');
|
||||||
|
|
||||||
|
if (await exists('./.env')) {
|
||||||
|
console.log('- Your existing .env file will be overwritten.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('- Press Ctrl+D to exit at any time.');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const vars: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
const DITTO_NSEC = Deno.env.get('DITTO_NSEC');
|
||||||
|
|
||||||
|
if (DITTO_NSEC) {
|
||||||
|
const choice = await question('list', 'Looks like you already have a DITTO_NSEC. Should we keep it?', [
|
||||||
|
'keep',
|
||||||
|
'create new (destructive)',
|
||||||
|
]);
|
||||||
|
if (choice === 'keep') {
|
||||||
|
vars.DITTO_NSEC = DITTO_NSEC;
|
||||||
|
}
|
||||||
|
if (choice === 'create new (destructive)') {
|
||||||
|
vars.DITTO_NSEC = nip19.nsecEncode(generateSecretKey());
|
||||||
|
console.log(' Generated secret key\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vars.DITTO_NSEC = nip19.nsecEncode(generateSecretKey());
|
||||||
|
console.log(' Generated secret key\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await question('input', 'What is the domain of your instance? (eg ditto.pub)', Conf.url.host);
|
||||||
|
vars.LOCAL_DOMAIN = `https://${domain}`;
|
||||||
|
|
||||||
|
const DATABASE_URL = Deno.env.get('DATABASE_URL');
|
||||||
|
|
||||||
|
if (DATABASE_URL) {
|
||||||
|
vars.DATABASE_URL = await question('input', 'Database URL', DATABASE_URL);
|
||||||
|
} else {
|
||||||
|
const database = await question('list', 'Which database do you want to use?', ['postgres', 'sqlite']);
|
||||||
|
if (database === 'sqlite') {
|
||||||
|
const path = await question('input', 'Path to SQLite database', 'data/db.sqlite3');
|
||||||
|
vars.DATABASE_URL = `sqlite://${path}`;
|
||||||
|
}
|
||||||
|
if (database === 'postgres') {
|
||||||
|
const host = await question('input', 'Postgres host', 'localhost');
|
||||||
|
const port = await question('input', 'Postgres port', '5432');
|
||||||
|
const user = await question('input', 'Postgres user', 'ditto');
|
||||||
|
const password = await question('input', 'Postgres password', 'ditto');
|
||||||
|
const database = await question('input', 'Postgres database', 'ditto');
|
||||||
|
vars.DATABASE_URL = `postgres://${user}:${password}@${host}:${port}/${database}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vars.DITTO_UPLOADER = await question('list', 'How do you want to upload files?', [
|
||||||
|
'nostrbuild',
|
||||||
|
'blossom',
|
||||||
|
's3',
|
||||||
|
'ipfs',
|
||||||
|
'local',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (vars.DITTO_UPLOADER === 'nostrbuild') {
|
||||||
|
vars.NOSTRBUILD_ENDPOINT = await question('input', 'nostr.build endpoint', Conf.nostrbuildEndpoint);
|
||||||
|
}
|
||||||
|
if (vars.DITTO_UPLOADER === 'blossom') {
|
||||||
|
vars.BLOSSOM_SERVERS = await question('input', 'Blossom servers (comma separated)', Conf.blossomServers.join(','));
|
||||||
|
}
|
||||||
|
if (vars.DITTO_UPLOADER === 's3') {
|
||||||
|
vars.S3_ACCESS_KEY = await question('input', 'S3 access key', Conf.s3.accessKey);
|
||||||
|
vars.S3_SECRET_KEY = await question('input', 'S3 secret key', Conf.s3.secretKey);
|
||||||
|
vars.S3_ENDPOINT = await question('input', 'S3 endpoint', Conf.s3.endPoint);
|
||||||
|
vars.S3_BUCKET = await question('input', 'S3 bucket', Conf.s3.bucket);
|
||||||
|
vars.S3_REGION = await question('input', 'S3 region', Conf.s3.region);
|
||||||
|
vars.S3_PATH_STYLE = String(await question('confirm', 'Use path style?', Conf.s3.pathStyle ?? false));
|
||||||
|
const mediaDomain = await question('input', 'Media domain', `media.${domain}`);
|
||||||
|
vars.MEDIA_DOMAIN = `https://${mediaDomain}`;
|
||||||
|
}
|
||||||
|
if (vars.DITTO_UPLOADER === 'ipfs') {
|
||||||
|
vars.IPFS_API_URL = await question('input', 'IPFS API URL', Conf.ipfs.apiUrl);
|
||||||
|
const mediaDomain = await question('input', 'Media domain', `media.${domain}`);
|
||||||
|
vars.MEDIA_DOMAIN = `https://${mediaDomain}`;
|
||||||
|
}
|
||||||
|
if (vars.DITTO_UPLOADER === 'local') {
|
||||||
|
vars.UPLOADS_DIR = await question('input', 'Local uploads directory', Conf.uploadsDir);
|
||||||
|
const mediaDomain = await question('input', 'Media domain', `media.${domain}`);
|
||||||
|
vars.MEDIA_DOMAIN = `https://${mediaDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Writing to .env file...');
|
||||||
|
|
||||||
|
const result = Object.entries(vars).reduce((acc, [key, value]) => {
|
||||||
|
if (value) {
|
||||||
|
return `${acc}${key}="${value}"\n`;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, '');
|
||||||
|
|
||||||
|
await Deno.writeTextFile('./.env', result);
|
||||||
|
|
||||||
|
console.log('Done');
|
||||||
44
scripts/trends.ts
Normal file
44
scripts/trends.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateTrendingEvents,
|
||||||
|
updateTrendingHashtags,
|
||||||
|
updateTrendingLinks,
|
||||||
|
updateTrendingPubkeys,
|
||||||
|
updateTrendingZappedEvents,
|
||||||
|
} from '@/trends.ts';
|
||||||
|
|
||||||
|
const trendSchema = z.enum(['pubkeys', 'zapped_events', 'events', 'hashtags', 'links']);
|
||||||
|
const trends = trendSchema.array().parse(Deno.args);
|
||||||
|
|
||||||
|
if (!trends.length) {
|
||||||
|
trends.push('pubkeys', 'zapped_events', 'events', 'hashtags', 'links');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trend of trends) {
|
||||||
|
switch (trend) {
|
||||||
|
case 'pubkeys':
|
||||||
|
console.log('Updating trending pubkeys...');
|
||||||
|
await updateTrendingPubkeys();
|
||||||
|
break;
|
||||||
|
case 'zapped_events':
|
||||||
|
console.log('Updating trending zapped events...');
|
||||||
|
await updateTrendingZappedEvents();
|
||||||
|
break;
|
||||||
|
case 'events':
|
||||||
|
console.log('Updating trending events...');
|
||||||
|
await updateTrendingEvents();
|
||||||
|
break;
|
||||||
|
case 'hashtags':
|
||||||
|
console.log('Updating trending hashtags...');
|
||||||
|
await updateTrendingHashtags();
|
||||||
|
break;
|
||||||
|
case 'links':
|
||||||
|
console.log('Updating trending links...');
|
||||||
|
await updateTrendingLinks();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Trends updated.');
|
||||||
|
Deno.exit(0);
|
||||||
65
src/app.ts
65
src/app.ts
|
|
@ -6,7 +6,6 @@ import { cors, logger, serveStatic } from 'hono/middleware';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { cron } from '@/cron.ts';
|
import { cron } from '@/cron.ts';
|
||||||
import { startFirehose } from '@/firehose.ts';
|
import { startFirehose } from '@/firehose.ts';
|
||||||
import { Time } from '@/utils.ts';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
accountController,
|
accountController,
|
||||||
|
|
@ -26,13 +25,27 @@ import {
|
||||||
updateCredentialsController,
|
updateCredentialsController,
|
||||||
verifyCredentialsController,
|
verifyCredentialsController,
|
||||||
} from '@/controllers/api/accounts.ts';
|
} from '@/controllers/api/accounts.ts';
|
||||||
import { adminAccountAction, adminAccountsController } from '@/controllers/api/admin.ts';
|
import {
|
||||||
|
adminAccountsController,
|
||||||
|
adminActionController,
|
||||||
|
adminApproveController,
|
||||||
|
adminRejectController,
|
||||||
|
} from '@/controllers/api/admin.ts';
|
||||||
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
||||||
import { blocksController } from '@/controllers/api/blocks.ts';
|
import { blocksController } from '@/controllers/api/blocks.ts';
|
||||||
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
||||||
import { adminRelaysController, adminSetRelaysController } from '@/controllers/api/ditto.ts';
|
import {
|
||||||
|
adminRelaysController,
|
||||||
|
adminSetRelaysController,
|
||||||
|
nameRequestController,
|
||||||
|
nameRequestsController,
|
||||||
|
} from '@/controllers/api/ditto.ts';
|
||||||
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
|
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
|
||||||
import { instanceController } from '@/controllers/api/instance.ts';
|
import {
|
||||||
|
instanceDescriptionController,
|
||||||
|
instanceV1Controller,
|
||||||
|
instanceV2Controller,
|
||||||
|
} from '@/controllers/api/instance.ts';
|
||||||
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
|
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
|
||||||
import { mediaController } from '@/controllers/api/media.ts';
|
import { mediaController } from '@/controllers/api/media.ts';
|
||||||
import { mutesController } from '@/controllers/api/mutes.ts';
|
import { mutesController } from '@/controllers/api/mutes.ts';
|
||||||
|
|
@ -42,6 +55,10 @@ import {
|
||||||
configController,
|
configController,
|
||||||
frontendConfigController,
|
frontendConfigController,
|
||||||
pleromaAdminDeleteStatusController,
|
pleromaAdminDeleteStatusController,
|
||||||
|
pleromaAdminSuggestController,
|
||||||
|
pleromaAdminTagController,
|
||||||
|
pleromaAdminUnsuggestController,
|
||||||
|
pleromaAdminUntagController,
|
||||||
updateConfigController,
|
updateConfigController,
|
||||||
} from '@/controllers/api/pleroma.ts';
|
} from '@/controllers/api/pleroma.ts';
|
||||||
import { preferencesController } from '@/controllers/api/preferences.ts';
|
import { preferencesController } from '@/controllers/api/preferences.ts';
|
||||||
|
|
@ -49,6 +66,7 @@ import { deleteReactionController, reactionController, reactionsController } fro
|
||||||
import { relayController } from '@/controllers/nostr/relay.ts';
|
import { relayController } from '@/controllers/nostr/relay.ts';
|
||||||
import {
|
import {
|
||||||
adminReportController,
|
adminReportController,
|
||||||
|
adminReportReopenController,
|
||||||
adminReportResolveController,
|
adminReportResolveController,
|
||||||
adminReportsController,
|
adminReportsController,
|
||||||
reportController,
|
reportController,
|
||||||
|
|
@ -70,6 +88,7 @@ import {
|
||||||
unpinController,
|
unpinController,
|
||||||
unreblogStatusController,
|
unreblogStatusController,
|
||||||
zapController,
|
zapController,
|
||||||
|
zappedByController,
|
||||||
} from '@/controllers/api/statuses.ts';
|
} from '@/controllers/api/statuses.ts';
|
||||||
import { streamingController } from '@/controllers/api/streaming.ts';
|
import { streamingController } from '@/controllers/api/streaming.ts';
|
||||||
import { suggestionsV1Controller, suggestionsV2Controller } from '@/controllers/api/suggestions.ts';
|
import { suggestionsV1Controller, suggestionsV2Controller } from '@/controllers/api/suggestions.ts';
|
||||||
|
|
@ -88,7 +107,6 @@ import { indexController } from '@/controllers/site.ts';
|
||||||
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
||||||
import { nostrController } from '@/controllers/well-known/nostr.ts';
|
import { nostrController } from '@/controllers/well-known/nostr.ts';
|
||||||
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
|
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
|
||||||
import { cacheMiddleware } from '@/middleware/cacheMiddleware.ts';
|
|
||||||
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
|
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
|
||||||
import { requireSigner } from '@/middleware/requireSigner.ts';
|
import { requireSigner } from '@/middleware/requireSigner.ts';
|
||||||
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
||||||
|
|
@ -114,7 +132,7 @@ type AppContext = Context<AppEnv>;
|
||||||
type AppMiddleware = MiddlewareHandler<AppEnv>;
|
type AppMiddleware = MiddlewareHandler<AppEnv>;
|
||||||
type AppController = Handler<AppEnv, any, HonoInput, Response | Promise<Response>>;
|
type AppController = Handler<AppEnv, any, HonoInput, Response | Promise<Response>>;
|
||||||
|
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>({ strict: false });
|
||||||
|
|
||||||
const debug = Debug('ditto:http');
|
const debug = Debug('ditto:http');
|
||||||
|
|
||||||
|
|
@ -126,14 +144,12 @@ if (Conf.cronEnabled) {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use('/api/*', logger(debug));
|
app.use('/api/*', logger(debug));
|
||||||
app.use('/relay/*', logger(debug));
|
|
||||||
app.use('/.well-known/*', logger(debug));
|
app.use('/.well-known/*', logger(debug));
|
||||||
app.use('/users/*', logger(debug));
|
app.use('/users/*', logger(debug));
|
||||||
app.use('/nodeinfo/*', logger(debug));
|
app.use('/nodeinfo/*', logger(debug));
|
||||||
app.use('/oauth/*', logger(debug));
|
app.use('/oauth/*', logger(debug));
|
||||||
|
|
||||||
app.get('/api/v1/streaming', streamingController);
|
app.get('/api/v1/streaming', streamingController);
|
||||||
app.get('/api/v1/streaming/', streamingController);
|
|
||||||
app.get('/relay', relayController);
|
app.get('/relay', relayController);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|
@ -151,7 +167,9 @@ app.get('/.well-known/nostr.json', nostrController);
|
||||||
|
|
||||||
app.get('/nodeinfo/:version', nodeInfoSchemaController);
|
app.get('/nodeinfo/:version', nodeInfoSchemaController);
|
||||||
|
|
||||||
app.get('/api/v1/instance', cacheMiddleware({ cacheName: 'web', expires: Time.minutes(5) }), instanceController);
|
app.get('/api/v1/instance', instanceV1Controller);
|
||||||
|
app.get('/api/v2/instance', instanceV2Controller);
|
||||||
|
app.get('/api/v1/instance/extended_description', instanceDescriptionController);
|
||||||
|
|
||||||
app.get('/api/v1/apps/verify_credentials', appCredentialsController);
|
app.get('/api/v1/apps/verify_credentials', appCredentialsController);
|
||||||
app.post('/api/v1/apps', createAppController);
|
app.post('/api/v1/apps', createAppController);
|
||||||
|
|
@ -231,7 +249,6 @@ app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactions
|
||||||
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController);
|
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController);
|
||||||
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController);
|
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController);
|
||||||
|
|
||||||
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
|
|
||||||
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
|
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
|
||||||
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
|
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
|
||||||
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController);
|
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController);
|
||||||
|
|
@ -239,7 +256,11 @@ app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAd
|
||||||
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController);
|
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController);
|
||||||
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
|
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
|
||||||
|
|
||||||
|
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
|
||||||
|
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
|
||||||
|
|
||||||
app.post('/api/v1/ditto/zap', requireSigner, zapController);
|
app.post('/api/v1/ditto/zap', requireSigner, zapController);
|
||||||
|
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
|
||||||
|
|
||||||
app.post('/api/v1/reports', requireSigner, reportController);
|
app.post('/api/v1/reports', requireSigner, reportController);
|
||||||
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
|
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
|
||||||
|
|
@ -250,8 +271,27 @@ app.post(
|
||||||
requireRole('admin'),
|
requireRole('admin'),
|
||||||
adminReportResolveController,
|
adminReportResolveController,
|
||||||
);
|
);
|
||||||
|
app.post(
|
||||||
|
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
|
||||||
|
requireSigner,
|
||||||
|
requireRole('admin'),
|
||||||
|
adminReportReopenController,
|
||||||
|
);
|
||||||
|
|
||||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction);
|
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
|
||||||
|
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController);
|
||||||
|
app.post(
|
||||||
|
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
|
||||||
|
requireSigner,
|
||||||
|
requireRole('admin'),
|
||||||
|
adminApproveController,
|
||||||
|
);
|
||||||
|
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController);
|
||||||
|
|
||||||
|
app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController);
|
||||||
|
app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController);
|
||||||
|
app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController);
|
||||||
|
app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController);
|
||||||
|
|
||||||
// Not (yet) implemented.
|
// Not (yet) implemented.
|
||||||
app.get('/api/v1/custom_emojis', emptyArrayController);
|
app.get('/api/v1/custom_emojis', emptyArrayController);
|
||||||
|
|
@ -261,6 +301,9 @@ app.get('/api/v1/conversations', emptyArrayController);
|
||||||
app.get('/api/v1/lists', emptyArrayController);
|
app.get('/api/v1/lists', emptyArrayController);
|
||||||
|
|
||||||
app.use('/api/*', notImplementedController);
|
app.use('/api/*', notImplementedController);
|
||||||
|
app.use('/.well-known/*', notImplementedController);
|
||||||
|
app.use('/nodeinfo/*', notImplementedController);
|
||||||
|
app.use('/oauth/*', notImplementedController);
|
||||||
|
|
||||||
const publicFiles = serveStatic({ root: './public/' });
|
const publicFiles = serveStatic({ root: './public/' });
|
||||||
const staticFiles = serveStatic({ root: './static/' });
|
const staticFiles = serveStatic({ root: './static/' });
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ await dotenv.load({
|
||||||
|
|
||||||
/** Application-wide configuration. */
|
/** Application-wide configuration. */
|
||||||
class Conf {
|
class Conf {
|
||||||
|
private static _pubkey: string | undefined;
|
||||||
/** Ditto admin secret key in nip19 format. This is the way it's configured by an admin. */
|
/** Ditto admin secret key in nip19 format. This is the way it's configured by an admin. */
|
||||||
static get nsec() {
|
static get nsec(): `nsec1${string}` {
|
||||||
const value = Deno.env.get('DITTO_NSEC');
|
const value = Deno.env.get('DITTO_NSEC');
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error('Missing DITTO_NSEC');
|
throw new Error('Missing DITTO_NSEC');
|
||||||
|
|
@ -25,13 +26,18 @@ class Conf {
|
||||||
return value as `nsec1${string}`;
|
return value as `nsec1${string}`;
|
||||||
}
|
}
|
||||||
/** Ditto admin secret key in hex format. */
|
/** Ditto admin secret key in hex format. */
|
||||||
static get seckey() {
|
static get seckey(): Uint8Array {
|
||||||
return nip19.decode(Conf.nsec).data;
|
return nip19.decode(Conf.nsec).data;
|
||||||
}
|
}
|
||||||
/** Ditto admin public key in hex format. */
|
/** Ditto admin public key in hex format. */
|
||||||
static pubkey = getPublicKey(Conf.seckey);
|
static get pubkey(): string {
|
||||||
|
if (!this._pubkey) {
|
||||||
|
this._pubkey = getPublicKey(Conf.seckey);
|
||||||
|
}
|
||||||
|
return this._pubkey;
|
||||||
|
}
|
||||||
/** Ditto admin secret key as a Web Crypto key. */
|
/** Ditto admin secret key as a Web Crypto key. */
|
||||||
static get cryptoKey() {
|
static get cryptoKey(): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey(
|
return crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
Conf.seckey,
|
Conf.seckey,
|
||||||
|
|
@ -41,7 +47,7 @@ class Conf {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static get port() {
|
static get port(): number {
|
||||||
return parseInt(Deno.env.get('PORT') || '4036');
|
return parseInt(Deno.env.get('PORT') || '4036');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,17 +56,13 @@ class Conf {
|
||||||
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
||||||
}
|
}
|
||||||
/** Relay to use for NIP-50 `search` queries. */
|
/** Relay to use for NIP-50 `search` queries. */
|
||||||
static get searchRelay() {
|
static get searchRelay(): string | undefined {
|
||||||
return Deno.env.get('SEARCH_RELAY');
|
return Deno.env.get('SEARCH_RELAY');
|
||||||
}
|
}
|
||||||
/** Origin of the Ditto server, including the protocol and port. */
|
/** Origin of the Ditto server, including the protocol and port. */
|
||||||
static get localDomain() {
|
static get localDomain(): string {
|
||||||
return Deno.env.get('LOCAL_DOMAIN') || `http://localhost:${Conf.port}`;
|
return Deno.env.get('LOCAL_DOMAIN') || `http://localhost:${Conf.port}`;
|
||||||
}
|
}
|
||||||
/** URL to an external Nostr viewer. */
|
|
||||||
static get externalDomain() {
|
|
||||||
return Deno.env.get('NOSTR_EXTERNAL') || Conf.localDomain;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Heroku-style database URL. This is used in production to connect to the
|
* Heroku-style database URL. This is used in production to connect to the
|
||||||
* database.
|
* database.
|
||||||
|
|
@ -76,7 +78,7 @@ class Conf {
|
||||||
}
|
}
|
||||||
static db = {
|
static db = {
|
||||||
get url(): url.UrlWithStringQuery {
|
get url(): url.UrlWithStringQuery {
|
||||||
return url.parse(Deno.env.get('DATABASE_URL') ?? 'sqlite://data/db.sqlite3');
|
return url.parse(Conf.databaseUrl);
|
||||||
},
|
},
|
||||||
get dialect(): 'sqlite' | 'postgres' | undefined {
|
get dialect(): 'sqlite' | 'postgres' | undefined {
|
||||||
switch (Conf.db.url.protocol) {
|
switch (Conf.db.url.protocol) {
|
||||||
|
|
@ -90,43 +92,43 @@ class Conf {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
/** Character limit to enforce for posts made through Mastodon API. */
|
/** Character limit to enforce for posts made through Mastodon API. */
|
||||||
static get postCharLimit() {
|
static get postCharLimit(): number {
|
||||||
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);
|
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);
|
||||||
}
|
}
|
||||||
/** S3 media storage configuration. */
|
/** S3 media storage configuration. */
|
||||||
static s3 = {
|
static s3 = {
|
||||||
get endPoint() {
|
get endPoint(): string | undefined {
|
||||||
return Deno.env.get('S3_ENDPOINT')!;
|
return Deno.env.get('S3_ENDPOINT');
|
||||||
},
|
},
|
||||||
get region() {
|
get region(): string | undefined {
|
||||||
return Deno.env.get('S3_REGION')!;
|
return Deno.env.get('S3_REGION');
|
||||||
},
|
},
|
||||||
get accessKey() {
|
get accessKey(): string | undefined {
|
||||||
return Deno.env.get('S3_ACCESS_KEY');
|
return Deno.env.get('S3_ACCESS_KEY');
|
||||||
},
|
},
|
||||||
get secretKey() {
|
get secretKey(): string | undefined {
|
||||||
return Deno.env.get('S3_SECRET_KEY');
|
return Deno.env.get('S3_SECRET_KEY');
|
||||||
},
|
},
|
||||||
get bucket() {
|
get bucket(): string | undefined {
|
||||||
return Deno.env.get('S3_BUCKET');
|
return Deno.env.get('S3_BUCKET');
|
||||||
},
|
},
|
||||||
get pathStyle() {
|
get pathStyle(): boolean | undefined {
|
||||||
return optionalBooleanSchema.parse(Deno.env.get('S3_PATH_STYLE'));
|
return optionalBooleanSchema.parse(Deno.env.get('S3_PATH_STYLE'));
|
||||||
},
|
},
|
||||||
get port() {
|
get port(): number | undefined {
|
||||||
return optionalNumberSchema.parse(Deno.env.get('S3_PORT'));
|
return optionalNumberSchema.parse(Deno.env.get('S3_PORT'));
|
||||||
},
|
},
|
||||||
get sessionToken() {
|
get sessionToken(): string | undefined {
|
||||||
return Deno.env.get('S3_SESSION_TOKEN');
|
return Deno.env.get('S3_SESSION_TOKEN');
|
||||||
},
|
},
|
||||||
get useSSL() {
|
get useSSL(): boolean | undefined {
|
||||||
return optionalBooleanSchema.parse(Deno.env.get('S3_USE_SSL'));
|
return optionalBooleanSchema.parse(Deno.env.get('S3_USE_SSL'));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
/** IPFS uploader configuration. */
|
/** IPFS uploader configuration. */
|
||||||
static ipfs = {
|
static ipfs = {
|
||||||
/** Base URL for private IPFS API calls. */
|
/** Base URL for private IPFS API calls. */
|
||||||
get apiUrl() {
|
get apiUrl(): string {
|
||||||
return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001';
|
return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -139,15 +141,15 @@ class Conf {
|
||||||
return Deno.env.get('BLOSSOM_SERVERS')?.split(',') || ['https://blossom.primal.net/'];
|
return Deno.env.get('BLOSSOM_SERVERS')?.split(',') || ['https://blossom.primal.net/'];
|
||||||
}
|
}
|
||||||
/** Module to upload files with. */
|
/** Module to upload files with. */
|
||||||
static get uploader() {
|
static get uploader(): string | undefined {
|
||||||
return Deno.env.get('DITTO_UPLOADER');
|
return Deno.env.get('DITTO_UPLOADER');
|
||||||
}
|
}
|
||||||
/** Location to use for local uploads. */
|
/** Location to use for local uploads. */
|
||||||
static get uploadsDir() {
|
static get uploadsDir(): string {
|
||||||
return Deno.env.get('UPLOADS_DIR') || 'data/uploads';
|
return Deno.env.get('UPLOADS_DIR') || 'data/uploads';
|
||||||
}
|
}
|
||||||
/** Media base URL for uploads. */
|
/** Media base URL for uploads. */
|
||||||
static get mediaDomain() {
|
static get mediaDomain(): string {
|
||||||
const value = Deno.env.get('MEDIA_DOMAIN');
|
const value = Deno.env.get('MEDIA_DOMAIN');
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -159,11 +161,11 @@ class Conf {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
/** Max upload size for files in number of bytes. Default 100MiB. */
|
/** Max upload size for files in number of bytes. Default 100MiB. */
|
||||||
static get maxUploadSize() {
|
static get maxUploadSize(): number {
|
||||||
return Number(Deno.env.get('MAX_UPLOAD_SIZE') || 100 * 1024 * 1024);
|
return Number(Deno.env.get('MAX_UPLOAD_SIZE') || 100 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
/** Usernames that regular users cannot sign up with. */
|
/** Usernames that regular users cannot sign up with. */
|
||||||
static get forbiddenUsernames() {
|
static get forbiddenUsernames(): string[] {
|
||||||
return Deno.env.get('FORBIDDEN_USERNAMES')?.split(',') || [
|
return Deno.env.get('FORBIDDEN_USERNAMES')?.split(',') || [
|
||||||
'_',
|
'_',
|
||||||
'admin',
|
'admin',
|
||||||
|
|
@ -175,24 +177,20 @@ class Conf {
|
||||||
}
|
}
|
||||||
/** Proof-of-work configuration. */
|
/** Proof-of-work configuration. */
|
||||||
static pow = {
|
static pow = {
|
||||||
get registrations() {
|
get registrations(): number {
|
||||||
return Number(Deno.env.get('DITTO_POW_REGISTRATIONS') ?? 20);
|
return Number(Deno.env.get('DITTO_POW_REGISTRATIONS') ?? 20);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
|
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
|
||||||
static get url() {
|
static get url(): URL {
|
||||||
return new URL(Conf.localDomain);
|
return new URL(Conf.localDomain);
|
||||||
}
|
}
|
||||||
/** Merges the path with the localDomain. */
|
/** Merges the path with the localDomain. */
|
||||||
static local(path: string): string {
|
static local(path: string): string {
|
||||||
return mergePaths(Conf.localDomain, path);
|
return mergePaths(Conf.localDomain, path);
|
||||||
}
|
}
|
||||||
/** Get an external URL for the NIP-19 identifier. */
|
|
||||||
static external(nip19: string): string {
|
|
||||||
return new URL(`/${nip19}`, Conf.externalDomain).toString();
|
|
||||||
}
|
|
||||||
/** URL to send Sentry errors to. */
|
/** URL to send Sentry errors to. */
|
||||||
static get sentryDsn() {
|
static get sentryDsn(): string | undefined {
|
||||||
return Deno.env.get('SENTRY_DSN');
|
return Deno.env.get('SENTRY_DSN');
|
||||||
}
|
}
|
||||||
/** SQLite settings. */
|
/** SQLite settings. */
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
|
import { NostrFilter } from '@nostrify/nostrify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { addTag } from '@/utils/tags.ts';
|
import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
|
||||||
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
|
import { renderNameRequest } from '@/views/ditto.ts';
|
||||||
|
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||||
|
|
||||||
const adminAccountQuerySchema = z.object({
|
const adminAccountQuerySchema = z.object({
|
||||||
local: booleanParamSchema.optional(),
|
local: booleanParamSchema.optional(),
|
||||||
|
|
@ -27,45 +28,103 @@ const adminAccountQuerySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminAccountsController: AppController = async (c) => {
|
const adminAccountsController: AppController = async (c) => {
|
||||||
|
const store = await Storages.db();
|
||||||
|
const params = paginationSchema.parse(c.req.query());
|
||||||
|
const { signal } = c.req.raw;
|
||||||
const {
|
const {
|
||||||
|
local,
|
||||||
pending,
|
pending,
|
||||||
disabled,
|
disabled,
|
||||||
silenced,
|
silenced,
|
||||||
suspended,
|
suspended,
|
||||||
sensitized,
|
sensitized,
|
||||||
|
staff,
|
||||||
} = adminAccountQuerySchema.parse(c.req.query());
|
} = adminAccountQuerySchema.parse(c.req.query());
|
||||||
|
|
||||||
// Not supported.
|
if (pending) {
|
||||||
if (pending || disabled || silenced || suspended || sensitized) {
|
if (disabled || silenced || suspended || sensitized) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await Storages.db();
|
const orig = await store.query(
|
||||||
const { since, until, limit } = paginationSchema.parse(c.req.query());
|
[{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }],
|
||||||
const { signal } = c.req.raw;
|
{ signal },
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { signal });
|
|
||||||
const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!);
|
|
||||||
const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
|
||||||
(event as DittoEvent).d_author = authors.find((author) => author.pubkey === d);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
|
||||||
events.map((event) => renderAdminAccount(event)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ids = new Set<string>(
|
||||||
|
orig
|
||||||
|
.map(({ tags }) => tags.find(([name]) => name === 'd')?.[1])
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = await store.query([{ kinds: [3036], ids: [...ids] }])
|
||||||
|
.then((events) => hydrateEvents({ store, events, signal }));
|
||||||
|
|
||||||
|
const nameRequests = await Promise.all(events.map(renderNameRequest));
|
||||||
|
return paginated(c, orig, nameRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabled || silenced || suspended || sensitized) {
|
||||||
|
const n = [];
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
n.push('disabled');
|
||||||
|
}
|
||||||
|
if (silenced) {
|
||||||
|
n.push('silenced');
|
||||||
|
}
|
||||||
|
if (suspended) {
|
||||||
|
n.push('suspended');
|
||||||
|
}
|
||||||
|
if (sensitized) {
|
||||||
|
n.push('sensitized');
|
||||||
|
}
|
||||||
|
if (staff) {
|
||||||
|
n.push('admin');
|
||||||
|
n.push('moderator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], '#n': n, ...params }], { signal });
|
||||||
|
|
||||||
|
const pubkeys = new Set<string>(
|
||||||
|
events
|
||||||
|
.map(({ tags }) => tags.find(([name]) => name === 'd')?.[1])
|
||||||
|
.filter((pubkey): pubkey is string => !!pubkey),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }])
|
||||||
|
.then((events) => hydrateEvents({ store, events, signal }));
|
||||||
|
|
||||||
|
const accounts = await Promise.all(
|
||||||
|
[...pubkeys].map((pubkey) => {
|
||||||
|
const author = authors.find((e) => e.pubkey === pubkey);
|
||||||
|
return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return paginated(c, events, accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter: NostrFilter = { kinds: [0], ...params };
|
||||||
|
|
||||||
|
if (local) {
|
||||||
|
filter.search = `domain:${Conf.url.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await store.query([filter], { signal })
|
||||||
|
.then((events) => hydrateEvents({ store, events, signal }));
|
||||||
|
|
||||||
|
const accounts = await Promise.all(events.map(renderAdminAccount));
|
||||||
return paginated(c, events, accounts);
|
return paginated(c, events, accounts);
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminAccountActionSchema = z.object({
|
const adminAccountActionSchema = z.object({
|
||||||
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']),
|
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend', 'revoke_name']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminAccountAction: AppController = async (c) => {
|
const adminActionController: AppController = async (c) => {
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
|
const store = await Storages.db();
|
||||||
const result = adminAccountActionSchema.safeParse(body);
|
const result = adminAccountActionSchema.safeParse(body);
|
||||||
const authorId = c.req.param('id');
|
const authorId = c.req.param('id');
|
||||||
|
|
||||||
|
|
@ -75,17 +134,84 @@ const adminAccountAction: AppController = async (c) => {
|
||||||
|
|
||||||
const { data } = result;
|
const { data } = result;
|
||||||
|
|
||||||
if (data.type !== 'disable') {
|
const n: Record<string, boolean> = {};
|
||||||
return c.json({ error: 'Record invalid' }, 422);
|
|
||||||
|
if (data.type === 'sensitive') {
|
||||||
|
n.sensitized = true;
|
||||||
|
}
|
||||||
|
if (data.type === 'disable') {
|
||||||
|
n.disabled = true;
|
||||||
|
}
|
||||||
|
if (data.type === 'silence') {
|
||||||
|
n.silenced = true;
|
||||||
|
}
|
||||||
|
if (data.type === 'suspend') {
|
||||||
|
n.suspended = true;
|
||||||
|
store.remove([{ authors: [authorId] }]).catch(console.warn);
|
||||||
|
}
|
||||||
|
if (data.type === 'revoke_name') {
|
||||||
|
n.revoke_name = true;
|
||||||
|
store.remove([{ kinds: [30360], authors: [Conf.pubkey], '#p': [authorId] }]).catch(console.warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateListAdminEvent(
|
await updateUser(authorId, n, c);
|
||||||
{ kinds: [10000], authors: [Conf.pubkey], limit: 1 },
|
|
||||||
(tags) => addTag(tags, ['p', authorId]),
|
|
||||||
c,
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({}, 200);
|
return c.json({}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { adminAccountAction, adminAccountsController };
|
const adminApproveController: AppController = async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const store = await Storages.db();
|
||||||
|
|
||||||
|
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = event.tags.find(([name]) => name === 'r')?.[1];
|
||||||
|
if (!r) {
|
||||||
|
return c.json({ error: 'NIP-05 not found' }, 404);
|
||||||
|
}
|
||||||
|
if (!z.string().email().safeParse(r).success) {
|
||||||
|
return c.json({ error: 'Invalid NIP-05' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await store.query([{ kinds: [30360], authors: [Conf.pubkey], '#d': [r], limit: 1 }]);
|
||||||
|
if (existing) {
|
||||||
|
return c.json({ error: 'NIP-05 already granted to another user' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAdminEvent({
|
||||||
|
kind: 30360,
|
||||||
|
tags: [
|
||||||
|
['d', r],
|
||||||
|
['L', 'nip05.domain'],
|
||||||
|
['l', r.split('@')[1], 'nip05.domain'],
|
||||||
|
['p', event.pubkey],
|
||||||
|
['e', event.id],
|
||||||
|
],
|
||||||
|
}, c);
|
||||||
|
|
||||||
|
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
|
||||||
|
await hydrateEvents({ events: [event], store });
|
||||||
|
|
||||||
|
const nameRequest = await renderNameRequest(event);
|
||||||
|
return c.json(nameRequest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminRejectController: AppController = async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const store = await Storages.db();
|
||||||
|
|
||||||
|
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c);
|
||||||
|
await hydrateEvents({ events: [event], store });
|
||||||
|
|
||||||
|
const nameRequest = await renderNameRequest(event);
|
||||||
|
return c.json(nameRequest);
|
||||||
|
};
|
||||||
|
export { adminAccountsController, adminActionController, adminApproveController, adminRejectController };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { NostrEvent } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
|
import { Storages } from '@/storages.ts';
|
||||||
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
|
import { createEvent, paginated, paginationSchema } from '@/utils/api.ts';
|
||||||
|
import { renderNameRequest } from '@/views/ditto.ts';
|
||||||
|
|
||||||
const markerSchema = z.enum(['read', 'write']);
|
const markerSchema = z.enum(['read', 'write']);
|
||||||
|
|
||||||
|
|
@ -58,3 +62,89 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
|
||||||
return acc;
|
return acc;
|
||||||
}, [] as RelayEntity[]);
|
}, [] as RelayEntity[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nameRequestSchema = z.object({
|
||||||
|
name: z.string().email(),
|
||||||
|
reason: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nameRequestController: AppController = async (c) => {
|
||||||
|
const store = await Storages.db();
|
||||||
|
const signer = c.get('signer')!;
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
|
const { name, reason } = nameRequestSchema.parse(await c.req.json());
|
||||||
|
|
||||||
|
const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]);
|
||||||
|
if (existing) {
|
||||||
|
return c.json({ error: 'Name request already exists' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await createEvent({
|
||||||
|
kind: 3036,
|
||||||
|
content: reason,
|
||||||
|
tags: [
|
||||||
|
['r', name],
|
||||||
|
['L', 'nip05.domain'],
|
||||||
|
['l', name.split('@')[1], 'nip05.domain'],
|
||||||
|
['p', Conf.pubkey],
|
||||||
|
],
|
||||||
|
}, c);
|
||||||
|
|
||||||
|
await hydrateEvents({ events: [event], store: await Storages.db() });
|
||||||
|
|
||||||
|
const nameRequest = await renderNameRequest(event);
|
||||||
|
return c.json(nameRequest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameRequestsSchema = z.object({
|
||||||
|
approved: booleanParamSchema.optional(),
|
||||||
|
rejected: booleanParamSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nameRequestsController: AppController = async (c) => {
|
||||||
|
const store = await Storages.db();
|
||||||
|
const signer = c.get('signer')!;
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
|
const params = paginationSchema.parse(c.req.query());
|
||||||
|
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
|
||||||
|
|
||||||
|
const filter: NostrFilter = {
|
||||||
|
kinds: [30383],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
|
'#k': ['3036'],
|
||||||
|
'#p': [pubkey],
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
filter['#n'] = ['approved'];
|
||||||
|
}
|
||||||
|
if (rejected) {
|
||||||
|
filter['#n'] = ['rejected'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const orig = await store.query([filter]);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
for (const event of orig) {
|
||||||
|
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
if (d) {
|
||||||
|
ids.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ids.size) {
|
||||||
|
return c.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await store.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
|
||||||
|
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
||||||
|
|
||||||
|
const nameRequests = await Promise.all(
|
||||||
|
events.map((event) => renderNameRequest(event)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return paginated(c, orig, nameRequests);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { Conf } from '@/config.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
|
||||||
const instanceController: AppController = async (c) => {
|
const version = '3.0.0 (compatible; Ditto 1.0.0)';
|
||||||
|
|
||||||
|
const instanceV1Controller: AppController = async (c) => {
|
||||||
const { host, protocol } = Conf.url;
|
const { host, protocol } = Conf.url;
|
||||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||||
|
|
||||||
|
|
@ -54,7 +56,7 @@ const instanceController: AppController = async (c) => {
|
||||||
urls: {
|
urls: {
|
||||||
streaming_api: `${wsProtocol}//${host}`,
|
streaming_api: `${wsProtocol}//${host}`,
|
||||||
},
|
},
|
||||||
version: '0.0.0 (compatible; Ditto 0.0.1)',
|
version,
|
||||||
email: meta.email,
|
email: meta.email,
|
||||||
nostr: {
|
nostr: {
|
||||||
pubkey: Conf.pubkey,
|
pubkey: Conf.pubkey,
|
||||||
|
|
@ -67,4 +69,86 @@ const instanceController: AppController = async (c) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export { instanceController };
|
const instanceV2Controller: AppController = async (c) => {
|
||||||
|
const { host, protocol } = Conf.url;
|
||||||
|
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||||
|
|
||||||
|
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
|
||||||
|
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
domain: host,
|
||||||
|
title: meta.name,
|
||||||
|
version,
|
||||||
|
source_url: 'https://gitlab.com/soapbox-pub/ditto',
|
||||||
|
description: meta.about,
|
||||||
|
usage: {
|
||||||
|
users: {
|
||||||
|
active_month: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
url: meta.picture,
|
||||||
|
blurhash: '',
|
||||||
|
versions: {
|
||||||
|
'@1x': meta.picture,
|
||||||
|
'@2x': meta.picture,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
languages: [
|
||||||
|
'en',
|
||||||
|
],
|
||||||
|
configuration: {
|
||||||
|
urls: {
|
||||||
|
streaming: `${wsProtocol}//${host}`,
|
||||||
|
},
|
||||||
|
vapid: {
|
||||||
|
public_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
max_featured_tags: 10,
|
||||||
|
max_pinned_statuses: 5,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
max_characters: Conf.postCharLimit,
|
||||||
|
max_media_attachments: 4,
|
||||||
|
characters_reserved_per_url: 23,
|
||||||
|
},
|
||||||
|
media_attachments: {
|
||||||
|
supported_mime_types: [],
|
||||||
|
image_size_limit: 16777216,
|
||||||
|
image_matrix_limit: 33177600,
|
||||||
|
video_size_limit: 103809024,
|
||||||
|
video_frame_rate_limit: 120,
|
||||||
|
video_matrix_limit: 8294400,
|
||||||
|
},
|
||||||
|
polls: {
|
||||||
|
max_options: 4,
|
||||||
|
max_characters_per_option: 50,
|
||||||
|
min_expiration: 300,
|
||||||
|
max_expiration: 2629746,
|
||||||
|
},
|
||||||
|
translation: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registrations: {
|
||||||
|
enabled: false,
|
||||||
|
approval_required: false,
|
||||||
|
message: null,
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const instanceDescriptionController: AppController = async (c) => {
|
||||||
|
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
content: meta.about,
|
||||||
|
updated_at: new Date((meta.event?.created_at ?? 0) * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { instanceDescriptionController, instanceV1Controller, instanceV2Controller };
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,87 @@
|
||||||
import { NostrFilter } from '@nostrify/nostrify';
|
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppContext, AppController } from '@/app.ts';
|
import { AppContext, AppController } from '@/app.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { paginated, paginationSchema } from '@/utils/api.ts';
|
import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts';
|
||||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||||
|
|
||||||
|
/** Set of known notification types across backends. */
|
||||||
|
const notificationTypes = new Set([
|
||||||
|
'mention',
|
||||||
|
'status',
|
||||||
|
'reblog',
|
||||||
|
'follow',
|
||||||
|
'follow_request',
|
||||||
|
'favourite',
|
||||||
|
'poll',
|
||||||
|
'update',
|
||||||
|
'admin.sign_up',
|
||||||
|
'admin.report',
|
||||||
|
'severed_relationships',
|
||||||
|
'pleroma:emoji_reaction',
|
||||||
|
'ditto:name_grant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const notificationsSchema = z.object({
|
||||||
|
account_id: n.id().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const notificationsController: AppController = async (c) => {
|
const notificationsController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||||
const { since, until } = paginationSchema.parse(c.req.query());
|
const params = paginationSchema.parse(c.req.query());
|
||||||
|
|
||||||
return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]);
|
const types = notificationTypes
|
||||||
|
.intersection(new Set(c.req.queries('types[]') ?? notificationTypes))
|
||||||
|
.difference(new Set(c.req.queries('exclude_types[]')));
|
||||||
|
|
||||||
|
const { account_id } = notificationsSchema.parse(c.req.query());
|
||||||
|
|
||||||
|
const kinds = new Set<number>();
|
||||||
|
|
||||||
|
if (types.has('mention')) {
|
||||||
|
kinds.add(1);
|
||||||
|
}
|
||||||
|
if (types.has('reblog')) {
|
||||||
|
kinds.add(6);
|
||||||
|
}
|
||||||
|
if (types.has('favourite') || types.has('pleroma:emoji_reaction')) {
|
||||||
|
kinds.add(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter: NostrFilter = {
|
||||||
|
kinds: [...kinds],
|
||||||
|
'#p': [pubkey],
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters: NostrFilter[] = [filter];
|
||||||
|
|
||||||
|
if (account_id) {
|
||||||
|
filter.authors = [account_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (types.has('ditto:name_grant') && !account_id) {
|
||||||
|
filters.push({ kinds: [30360], authors: [Conf.pubkey], '#p': [pubkey], ...params });
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderNotifications(filters, types, params, c);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
|
async function renderNotifications(
|
||||||
|
filters: NostrFilter[],
|
||||||
|
types: Set<string>,
|
||||||
|
params: PaginationParams,
|
||||||
|
c: AppContext,
|
||||||
|
) {
|
||||||
const store = c.get('store');
|
const store = c.get('store');
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||||
const { signal } = c.req.raw;
|
const { signal } = c.req.raw;
|
||||||
|
const opts = { signal, limit: params.limit };
|
||||||
|
|
||||||
const events = await store
|
const events = await store
|
||||||
.query(filters, { signal })
|
.query(filters, opts)
|
||||||
.then((events) => events.filter((event) => event.pubkey !== pubkey))
|
.then((events) => events.filter((event) => event.pubkey !== pubkey))
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, store, signal }));
|
||||||
|
|
||||||
|
|
@ -26,9 +89,8 @@ async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifications = (await Promise
|
const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
|
||||||
.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
|
.filter((notification) => notification && types.has(notification.type));
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (!notifications.length) {
|
if (!notifications.length) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ const oauthController: AppController = (c) => {
|
||||||
return c.text('Missing `redirect_uri` query param.', 422);
|
return c.text('Missing `redirect_uri` query param.', 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = c.req.query('state');
|
||||||
const redirectUri = maybeDecodeUri(encodedUri);
|
const redirectUri = maybeDecodeUri(encodedUri);
|
||||||
|
|
||||||
return c.html(`<!DOCTYPE html>
|
return c.html(`<!DOCTYPE html>
|
||||||
|
|
@ -162,6 +163,7 @@ const oauthController: AppController = (c) => {
|
||||||
<form id="oauth_form" action="/oauth/authorize" method="post">
|
<form id="oauth_form" action="/oauth/authorize" method="post">
|
||||||
<input type="text" placeholder="bunker://..." name="bunker_uri" autocomplete="off" required>
|
<input type="text" placeholder="bunker://..." name="bunker_uri" autocomplete="off" required>
|
||||||
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${escape(redirectUri)}">
|
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${escape(redirectUri)}">
|
||||||
|
<input type="hidden" name="state" value="${escape(state ?? '')}">
|
||||||
<button type="submit">Authorize</button>
|
<button type="submit">Authorize</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Sign in with a Nostr bunker app. Please configure the app to use this relay: ${Conf.relay}</p>
|
<p>Sign in with a Nostr bunker app. Please configure the app to use this relay: ${Conf.relay}</p>
|
||||||
|
|
@ -187,6 +189,7 @@ function maybeDecodeUri(uri: string): string {
|
||||||
const oauthAuthorizeSchema = z.object({
|
const oauthAuthorizeSchema = z.object({
|
||||||
bunker_uri: z.string().url().refine((v) => v.startsWith('bunker://')),
|
bunker_uri: z.string().url().refine((v) => v.startsWith('bunker://')),
|
||||||
redirect_uri: z.string().url(),
|
redirect_uri: z.string().url(),
|
||||||
|
state: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Controller the OAuth form is POSTed to. */
|
/** Controller the OAuth form is POSTed to. */
|
||||||
|
|
@ -199,7 +202,7 @@ const oauthAuthorizeController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed FormData values.
|
// Parsed FormData values.
|
||||||
const { bunker_uri, redirect_uri: redirectUri } = result.data;
|
const { bunker_uri, redirect_uri: redirectUri, state } = result.data;
|
||||||
|
|
||||||
const bunker = new URL(bunker_uri);
|
const bunker = new URL(bunker_uri);
|
||||||
|
|
||||||
|
|
@ -209,17 +212,26 @@ const oauthAuthorizeController: AppController = async (c) => {
|
||||||
relays: bunker.searchParams.getAll('relay'),
|
relays: bunker.searchParams.getAll('relay'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = addCodeToRedirectUri(redirectUri, token);
|
if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
|
||||||
|
return c.text(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = addCodeToRedirectUri(redirectUri, token, state);
|
||||||
|
|
||||||
return c.redirect(url);
|
return c.redirect(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Append the given `code` as a query param to the `redirect_uri`. */
|
/** Append the given `code` as a query param to the `redirect_uri`. */
|
||||||
function addCodeToRedirectUri(redirectUri: string, code: string): string {
|
function addCodeToRedirectUri(redirectUri: string, code: string, state?: string): string {
|
||||||
const url = new URL(redirectUri);
|
const url = new URL(redirectUri);
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
|
|
||||||
q.set('code', code);
|
q.set('code', code);
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
q.set('state', state);
|
||||||
|
}
|
||||||
|
|
||||||
url.search = q.toString();
|
url.search = q.toString();
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { Conf } from '@/config.ts';
|
||||||
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
|
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { createAdminEvent } from '@/utils/api.ts';
|
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
|
||||||
|
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||||
|
|
||||||
const frontendConfigController: AppController = async (c) => {
|
const frontendConfigController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const store = await Storages.db();
|
||||||
|
|
@ -87,4 +88,100 @@ async function getConfigs(store: NStore, signal: AbortSignal): Promise<PleromaCo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { configController, frontendConfigController, pleromaAdminDeleteStatusController, updateConfigController };
|
const pleromaAdminTagSchema = z.object({
|
||||||
|
nicknames: z.string().array(),
|
||||||
|
tags: z.string().array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pleromaAdminTagController: AppController = async (c) => {
|
||||||
|
const params = pleromaAdminTagSchema.parse(await c.req.json());
|
||||||
|
|
||||||
|
for (const nickname of params.nicknames) {
|
||||||
|
const pubkey = await lookupPubkey(nickname);
|
||||||
|
if (!pubkey) continue;
|
||||||
|
|
||||||
|
await updateAdminEvent(
|
||||||
|
{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 },
|
||||||
|
(prev) => {
|
||||||
|
const tags = prev?.tags ?? [['d', pubkey]];
|
||||||
|
|
||||||
|
for (const tag of params.tags) {
|
||||||
|
const existing = prev?.tags.some(([name, value]) => name === 't' && value === tag);
|
||||||
|
if (!existing) {
|
||||||
|
tags.push(['t', tag]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 30382,
|
||||||
|
content: prev?.content ?? '',
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pleromaAdminUntagController: AppController = async (c) => {
|
||||||
|
const params = pleromaAdminTagSchema.parse(await c.req.json());
|
||||||
|
|
||||||
|
for (const nickname of params.nicknames) {
|
||||||
|
const pubkey = await lookupPubkey(nickname);
|
||||||
|
if (!pubkey) continue;
|
||||||
|
|
||||||
|
await updateAdminEvent(
|
||||||
|
{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 },
|
||||||
|
(prev) => ({
|
||||||
|
kind: 30382,
|
||||||
|
content: prev?.content ?? '',
|
||||||
|
tags: (prev?.tags ?? [['d', pubkey]])
|
||||||
|
.filter(([name, value]) => !(name === 't' && params.tags.includes(value))),
|
||||||
|
}),
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pleromaAdminSuggestSchema = z.object({
|
||||||
|
nicknames: z.string().array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pleromaAdminSuggestController: AppController = async (c) => {
|
||||||
|
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
|
||||||
|
|
||||||
|
for (const nickname of nicknames) {
|
||||||
|
const pubkey = await lookupPubkey(nickname);
|
||||||
|
if (!pubkey) continue;
|
||||||
|
await updateUser(pubkey, { suggested: true }, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pleromaAdminUnsuggestController: AppController = async (c) => {
|
||||||
|
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
|
||||||
|
|
||||||
|
for (const nickname of nicknames) {
|
||||||
|
const pubkey = await lookupPubkey(nickname);
|
||||||
|
if (!pubkey) continue;
|
||||||
|
await updateUser(pubkey, { suggested: false }, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
configController,
|
||||||
|
frontendConfigController,
|
||||||
|
pleromaAdminDeleteStatusController,
|
||||||
|
pleromaAdminSuggestController,
|
||||||
|
pleromaAdminTagController,
|
||||||
|
pleromaAdminUnsuggestController,
|
||||||
|
pleromaAdminUntagController,
|
||||||
|
updateConfigController,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { createAdminEvent, createEvent, parseBody } from '@/utils/api.ts';
|
import { createEvent, paginated, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { renderAdminReport } from '@/views/mastodon/reports.ts';
|
import { renderAdminReport } from '@/views/mastodon/reports.ts';
|
||||||
import { renderReport } from '@/views/mastodon/reports.ts';
|
import { renderReport } from '@/views/mastodon/reports.ts';
|
||||||
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
|
|
||||||
const reportSchema = z.object({
|
const reportSchema = z.object({
|
||||||
account_id: n.id(),
|
account_id: n.id(),
|
||||||
|
|
@ -52,20 +53,55 @@ const reportController: AppController = async (c) => {
|
||||||
return c.json(await renderReport(event));
|
return c.json(await renderReport(event));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adminReportsSchema = z.object({
|
||||||
|
resolved: booleanParamSchema.optional(),
|
||||||
|
account_id: n.id().optional(),
|
||||||
|
target_account_id: n.id().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
|
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
|
||||||
const adminReportsController: AppController = async (c) => {
|
const adminReportsController: AppController = async (c) => {
|
||||||
const store = c.get('store');
|
const store = c.get('store');
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||||
|
|
||||||
const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
|
const params = paginationSchema.parse(c.req.query());
|
||||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }))
|
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
|
||||||
.then((events) =>
|
|
||||||
Promise.all(
|
const filter: NostrFilter = {
|
||||||
|
kinds: [30383],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
|
'#k': ['1984'],
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof resolved === 'boolean') {
|
||||||
|
filter['#n'] = [resolved ? 'closed' : 'open'];
|
||||||
|
}
|
||||||
|
if (account_id) {
|
||||||
|
filter['#p'] = [account_id];
|
||||||
|
}
|
||||||
|
if (target_account_id) {
|
||||||
|
filter['#P'] = [target_account_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const orig = await store.query([filter]);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
for (const event of orig) {
|
||||||
|
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
if (d) {
|
||||||
|
ids.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await store.query([{ kinds: [1984], ids: [...ids] }])
|
||||||
|
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
||||||
|
|
||||||
|
const reports = await Promise.all(
|
||||||
events.map((event) => renderAdminReport(event, { viewerPubkey })),
|
events.map((event) => renderAdminReport(event, { viewerPubkey })),
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json(reports);
|
return paginated(c, orig, reports);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
|
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
|
||||||
|
|
@ -82,12 +118,13 @@ const adminReportController: AppController = async (c) => {
|
||||||
}], { signal });
|
}], { signal });
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'This action is not allowed' }, 403);
|
return c.json({ error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], store, signal });
|
||||||
|
|
||||||
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey }));
|
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||||
|
return c.json(report);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
|
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
|
||||||
|
|
@ -104,18 +141,43 @@ const adminReportResolveController: AppController = async (c) => {
|
||||||
}], { signal });
|
}], { signal });
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'This action is not allowed' }, 403);
|
return c.json({ error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateEventInfo(eventId, { open: false, closed: true }, c);
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], store, signal });
|
||||||
|
|
||||||
await createAdminEvent({
|
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||||
kind: 5,
|
return c.json(report);
|
||||||
tags: [['e', event.id]],
|
|
||||||
content: 'Report closed.',
|
|
||||||
}, c);
|
|
||||||
|
|
||||||
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { adminReportController, adminReportResolveController, adminReportsController, reportController };
|
const adminReportReopenController: AppController = async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const { signal } = c.req.raw;
|
||||||
|
const store = c.get('store');
|
||||||
|
const pubkey = await c.get('signer')?.getPublicKey();
|
||||||
|
|
||||||
|
const [event] = await store.query([{
|
||||||
|
kinds: [1984],
|
||||||
|
ids: [eventId],
|
||||||
|
limit: 1,
|
||||||
|
}], { signal });
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEventInfo(eventId, { open: true, closed: false }, c);
|
||||||
|
await hydrateEvents({ events: [event], store, signal });
|
||||||
|
|
||||||
|
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||||
|
return c.json(report);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
adminReportController,
|
||||||
|
adminReportReopenController,
|
||||||
|
adminReportResolveController,
|
||||||
|
adminReportsController,
|
||||||
|
reportController,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { lookupPubkey } from '@/utils/lookup.ts';
|
||||||
import { addTag, deleteTag } from '@/utils/tags.ts';
|
import { addTag, deleteTag } from '@/utils/tags.ts';
|
||||||
import { asyncReplaceAll } from '@/utils/text.ts';
|
import { asyncReplaceAll } from '@/utils/text.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
|
|
||||||
const createStatusSchema = z.object({
|
const createStatusSchema = z.object({
|
||||||
in_reply_to_id: n.id().nullish(),
|
in_reply_to_id: n.id().nullish(),
|
||||||
|
|
@ -104,6 +105,11 @@ const createStatusController: AppController = async (c) => {
|
||||||
tags.push(['subject', data.spoiler_text]);
|
tags.push(['subject', data.spoiler_text]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.language) {
|
||||||
|
tags.push(['L', 'ISO-639-1']);
|
||||||
|
tags.push(['l', data.language, 'ISO-639-1']);
|
||||||
|
}
|
||||||
|
|
||||||
const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
|
const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
|
||||||
|
|
||||||
const imeta: string[][] = media.map(({ data }) => {
|
const imeta: string[][] = media.map(({ data }) => {
|
||||||
|
|
@ -536,6 +542,40 @@ const zapController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zappedByController: AppController = async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const store = await Storages.db();
|
||||||
|
const amountSchema = z.coerce.number().int().nonnegative().catch(0);
|
||||||
|
|
||||||
|
const events: DittoEvent[] = (await store.query([{ kinds: [9735], '#e': [id], limit: 100 }])).map((event) => {
|
||||||
|
const zapRequest = event.tags.find(([name]) => name === 'description')?.[1];
|
||||||
|
if (!zapRequest) return;
|
||||||
|
try {
|
||||||
|
return JSON.parse(zapRequest);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
await hydrateEvents({ events, store });
|
||||||
|
|
||||||
|
const results = (await Promise.all(
|
||||||
|
events.map(async (event) => {
|
||||||
|
const amount = amountSchema.parse(event.tags.find(([name]) => name === 'amount')?.[1]);
|
||||||
|
const comment = event?.content ?? '';
|
||||||
|
const account = event?.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
comment,
|
||||||
|
amount,
|
||||||
|
account,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)).filter(Boolean);
|
||||||
|
|
||||||
|
return c.json(results);
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
bookmarkController,
|
bookmarkController,
|
||||||
contextController,
|
contextController,
|
||||||
|
|
@ -552,4 +592,5 @@ export {
|
||||||
unpinController,
|
unpinController,
|
||||||
unreblogStatusController,
|
unreblogStatusController,
|
||||||
zapController,
|
zapController,
|
||||||
|
zappedByController,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NostrFilter } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
import Debug from '@soapbox/stickynotes/debug';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { bech32ToPubkey } from '@/utils.ts';
|
import { bech32ToPubkey } from '@/utils.ts';
|
||||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:streaming');
|
const debug = Debug('ditto:streaming');
|
||||||
|
|
||||||
|
|
@ -52,6 +53,11 @@ const streamingController: AppController = async (c) => {
|
||||||
|
|
||||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
||||||
|
|
||||||
|
const store = await Storages.db();
|
||||||
|
const pubsub = await Storages.pubsub();
|
||||||
|
|
||||||
|
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined;
|
||||||
|
|
||||||
function send(name: string, payload: object) {
|
function send(name: string, payload: object) {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
debug('send', name, JSON.stringify(payload));
|
debug('send', name, JSON.stringify(payload));
|
||||||
|
|
@ -63,52 +69,54 @@ const streamingController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onopen = async () => {
|
async function sub(type: string, filters: NostrFilter[], render: (event: NostrEvent) => Promise<unknown>) {
|
||||||
if (!stream) return;
|
|
||||||
|
|
||||||
const filter = await topicToFilter(stream, c.req.query(), pubkey);
|
|
||||||
if (!filter) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await Storages.db();
|
for await (const msg of pubsub.req(filters, { signal: controller.signal })) {
|
||||||
const pubsub = await Storages.pubsub();
|
|
||||||
|
|
||||||
for await (const msg of pubsub.req([filter], { signal: controller.signal })) {
|
|
||||||
if (msg[0] === 'EVENT') {
|
if (msg[0] === 'EVENT') {
|
||||||
const event = msg[2];
|
const event = msg[2];
|
||||||
|
|
||||||
if (pubkey) {
|
if (policy) {
|
||||||
const policy = new MuteListPolicy(pubkey, await Storages.admin());
|
|
||||||
const [, , ok] = await policy.call(event);
|
const [, , ok] = await policy.call(event);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) });
|
||||||
events: [event],
|
|
||||||
store: db,
|
|
||||||
signal: AbortSignal.timeout(1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (event.kind === 1) {
|
const result = await render(event);
|
||||||
const status = await renderStatus(event, { viewerPubkey: pubkey });
|
|
||||||
if (status) {
|
|
||||||
send('update', status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === 6) {
|
if (result) {
|
||||||
const status = await renderReblog(event, { viewerPubkey: pubkey });
|
send(type, result);
|
||||||
if (status) {
|
|
||||||
send('update', status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug('streaming error:', e);
|
debug('streaming error:', e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onopen = async () => {
|
||||||
|
if (!stream) return;
|
||||||
|
const topicFilter = await topicToFilter(stream, c.req.query(), pubkey);
|
||||||
|
|
||||||
|
if (topicFilter) {
|
||||||
|
sub('update', [topicFilter], async (event) => {
|
||||||
|
if (event.kind === 1) {
|
||||||
|
return await renderStatus(event, { viewerPubkey: pubkey });
|
||||||
|
}
|
||||||
|
if (event.kind === 6) {
|
||||||
|
return await renderReblog(event, { viewerPubkey: pubkey });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['user', 'user:notification'].includes(stream) && pubkey) {
|
||||||
|
sub('notification', [{ '#p': [pubkey] }], async (event) => {
|
||||||
|
return await renderNotification(event, { viewerPubkey: pubkey });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
|
||||||
const pubkey = await signer?.getPublicKey();
|
const pubkey = await signer?.getPublicKey();
|
||||||
|
|
||||||
const filters: NostrFilter[] = [
|
const filters: NostrFilter[] = [
|
||||||
{ kinds: [3], authors: [Conf.pubkey], limit: 1 },
|
{ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'], limit },
|
||||||
{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 },
|
{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -42,8 +42,8 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
|
||||||
|
|
||||||
const events = await store.query(filters, { signal });
|
const events = await store.query(filters, { signal });
|
||||||
|
|
||||||
const [suggestedEvent, followsEvent, mutesEvent, trendingEvent] = [
|
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
|
||||||
events.find((event) => matchFilter({ kinds: [3], authors: [Conf.pubkey] }, event)),
|
events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'] }, event)),
|
||||||
pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined,
|
pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined,
|
||||||
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
|
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
|
||||||
events.find((event) =>
|
events.find((event) =>
|
||||||
|
|
@ -51,8 +51,13 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const [suggested, trending, follows, mutes] = [
|
const suggested = new Set(
|
||||||
getTagSet(suggestedEvent?.tags ?? [], 'p'),
|
userEvents
|
||||||
|
.map((event) => event.tags.find(([name]) => name === 'd')?.[1])
|
||||||
|
.filter((pubkey): pubkey is string => !!pubkey),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [trending, follows, mutes] = [
|
||||||
getTagSet(trendingEvent?.tags ?? [], 'p'),
|
getTagSet(trendingEvent?.tags ?? [], 'p'),
|
||||||
getTagSet(followsEvent?.tags ?? [], 'p'),
|
getTagSet(followsEvent?.tags ?? [], 'p'),
|
||||||
getTagSet(mutesEvent?.tags ?? [], 'p'),
|
getTagSet(mutesEvent?.tags ?? [], 'p'),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NostrEvent, NStore } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
|
|
@ -11,7 +11,7 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
|
||||||
let trendingHashtagsCache = getTrendingHashtags();
|
let trendingHashtagsCache = getTrendingHashtags();
|
||||||
|
|
||||||
Deno.cron('update trending hashtags cache', { minute: { every: 15 } }, async () => {
|
Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
|
||||||
const trends = await getTrendingHashtags();
|
const trends = await getTrendingHashtags();
|
||||||
trendingHashtagsCache = Promise.resolve(trends);
|
trendingHashtagsCache = Promise.resolve(trends);
|
||||||
});
|
});
|
||||||
|
|
@ -50,7 +50,7 @@ async function getTrendingHashtags() {
|
||||||
|
|
||||||
let trendingLinksCache = getTrendingLinks();
|
let trendingLinksCache = getTrendingLinks();
|
||||||
|
|
||||||
Deno.cron('update trending links cache', { minute: { every: 15 } }, async () => {
|
Deno.cron('update trending links cache', '50 * * * *', async () => {
|
||||||
const trends = await getTrendingLinks();
|
const trends = await getTrendingLinks();
|
||||||
trendingLinksCache = Promise.resolve(trends);
|
trendingLinksCache = Promise.resolve(trends);
|
||||||
});
|
});
|
||||||
|
|
@ -148,58 +148,56 @@ interface TrendingTag {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTrendingTags(store: NStore, tagName: string): Promise<TrendingTag[]> {
|
export async function getTrendingTags(store: NStore, tagName: string): Promise<TrendingTag[]> {
|
||||||
const filter = {
|
const [label] = await store.query([{
|
||||||
kinds: [1985],
|
kinds: [1985],
|
||||||
'#L': ['pub.ditto.trends'],
|
'#L': ['pub.ditto.trends'],
|
||||||
'#l': [`#${tagName}`],
|
'#l': [`#${tagName}`],
|
||||||
authors: [Conf.pubkey],
|
authors: [Conf.pubkey],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
};
|
}]);
|
||||||
|
|
||||||
const [label] = await store.query([filter]);
|
if (!label) return [];
|
||||||
|
|
||||||
if (!label) {
|
const date = new Date(label.created_at * 1000);
|
||||||
return [];
|
const lastWeek = new Date(date.getTime() - Time.days(7));
|
||||||
}
|
const dates = generateDateRange(lastWeek, date).reverse();
|
||||||
|
|
||||||
const tags = label.tags.filter(([name]) => name === tagName);
|
const results: TrendingTag[] = [];
|
||||||
|
|
||||||
const now = new Date();
|
for (const [name, value] of label.tags) {
|
||||||
const lastWeek = new Date(now.getTime() - Time.days(7));
|
if (name !== tagName) continue;
|
||||||
const dates = generateDateRange(lastWeek, now).reverse();
|
|
||||||
|
|
||||||
return Promise.all(tags.map(async ([_, value]) => {
|
const history: TrendingTag['history'] = [];
|
||||||
const filters = dates.map((date) => ({
|
|
||||||
...filter,
|
for (const date of dates) {
|
||||||
|
const [label] = await store.query([{
|
||||||
|
kinds: [1985],
|
||||||
|
'#L': ['pub.ditto.trends'],
|
||||||
|
'#l': [`#${tagName}`],
|
||||||
[`#${tagName}`]: [value],
|
[`#${tagName}`]: [value],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
since: Math.floor(date.getTime() / 1000),
|
since: Math.floor(date.getTime() / 1000),
|
||||||
until: Math.floor((date.getTime() + Time.days(1)) / 1000),
|
until: Math.floor((date.getTime() + Time.days(1)) / 1000),
|
||||||
}));
|
limit: 1,
|
||||||
|
} as NostrFilter]);
|
||||||
|
|
||||||
const labels = await store.query(filters);
|
const [, , , accounts, uses] = label?.tags.find(([n, v]) => n === tagName && v === value) ?? [];
|
||||||
|
|
||||||
const history = dates.map((date) => {
|
history.push({
|
||||||
const label = labels.find((label) => {
|
|
||||||
const since = Math.floor(date.getTime() / 1000);
|
|
||||||
const until = Math.floor((date.getTime() + Time.days(1)) / 1000);
|
|
||||||
return label.created_at >= since && label.created_at < until;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, , , accounts, uses] = label?.tags.find((tag) => tag[0] === tagName && tag[1] === value) ?? [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
day: Math.floor(date.getTime() / 1000),
|
day: Math.floor(date.getTime() / 1000),
|
||||||
authors: Number(accounts || 0),
|
authors: Number(accounts || 0),
|
||||||
uses: Number(uses || 0),
|
uses: Number(uses || 0),
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
results.push({
|
||||||
name: tagName,
|
name: tagName,
|
||||||
value,
|
value,
|
||||||
history,
|
history,
|
||||||
};
|
});
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { trendingLinksController, trendingStatusesController, trendingTagsController };
|
export { trendingLinksController, trendingStatusesController, trendingTagsController };
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ const nameSchema = z.string().min(1).regex(/^\w+$/);
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/05.md
|
* https://github.com/nostr-protocol/nips/blob/master/05.md
|
||||||
*/
|
*/
|
||||||
const nostrController: AppController = async (c) => {
|
const nostrController: AppController = async (c) => {
|
||||||
|
const store = c.get('store');
|
||||||
|
|
||||||
const result = nameSchema.safeParse(c.req.query('name'));
|
const result = nameSchema.safeParse(c.req.query('name'));
|
||||||
const name = result.success ? result.data : undefined;
|
const name = result.success ? result.data : undefined;
|
||||||
const pointer = name ? await localNip05Lookup(c.get('store'), name) : undefined;
|
|
||||||
|
const pointer = name ? await localNip05Lookup(store, name) : undefined;
|
||||||
|
|
||||||
if (!name || !pointer) {
|
if (!name || !pointer) {
|
||||||
return c.json({ names: {}, relays: {} });
|
return c.json({ names: {}, relays: {} });
|
||||||
|
|
|
||||||
88
src/cron.ts
88
src/cron.ts
|
|
@ -1,84 +1,12 @@
|
||||||
import { Stickynotes } from '@soapbox/stickynotes';
|
import { updateTrendingLinks } from '@/trends.ts';
|
||||||
|
import { updateTrendingHashtags } from '@/trends.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { updateTrendingEvents, updateTrendingPubkeys, updateTrendingZappedEvents } from '@/trends.ts';
|
||||||
import { DittoDB } from '@/db/DittoDB.ts';
|
|
||||||
import { handleEvent } from '@/pipeline.ts';
|
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
|
||||||
import { getTrendingTagValues } from '@/trends/trending-tag-values.ts';
|
|
||||||
import { Time } from '@/utils/time.ts';
|
|
||||||
|
|
||||||
const console = new Stickynotes('ditto:trends');
|
|
||||||
|
|
||||||
async function updateTrendingTags(
|
|
||||||
l: string,
|
|
||||||
tagName: string,
|
|
||||||
kinds: number[],
|
|
||||||
limit: number,
|
|
||||||
extra = '',
|
|
||||||
aliases?: string[],
|
|
||||||
) {
|
|
||||||
console.info(`Updating trending ${l}...`);
|
|
||||||
const kysely = await DittoDB.getInstance();
|
|
||||||
const signal = AbortSignal.timeout(1000);
|
|
||||||
|
|
||||||
const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const tagNames = aliases ? [tagName, ...aliases] : [tagName];
|
|
||||||
|
|
||||||
const trends = await getTrendingTagValues(kysely, tagNames, {
|
|
||||||
kinds,
|
|
||||||
since: yesterday,
|
|
||||||
until: now,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!trends.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const signer = new AdminSigner();
|
|
||||||
|
|
||||||
const label = await signer.signEvent({
|
|
||||||
kind: 1985,
|
|
||||||
content: '',
|
|
||||||
tags: [
|
|
||||||
['L', 'pub.ditto.trends'],
|
|
||||||
['l', l, 'pub.ditto.trends'],
|
|
||||||
...trends.map(({ value, authors, uses }) => [tagName, value, extra, authors.toString(), uses.toString()]),
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
await handleEvent(label, signal);
|
|
||||||
console.info(`Trending ${l} updated.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start cron jobs for the application. */
|
/** Start cron jobs for the application. */
|
||||||
export function cron() {
|
export function cron() {
|
||||||
Deno.cron(
|
Deno.cron('update trending pubkeys', '0 * * * *', updateTrendingPubkeys);
|
||||||
'update trending pubkeys',
|
Deno.cron('update trending zapped events', '7 * * * *', updateTrendingZappedEvents);
|
||||||
'0 * * * *',
|
Deno.cron('update trending events', '15 * * * *', updateTrendingEvents);
|
||||||
() => updateTrendingTags('#p', 'p', [1, 6, 7, 9735], 40, Conf.relay),
|
Deno.cron('update trending hashtags', '30 * * * *', updateTrendingHashtags);
|
||||||
);
|
Deno.cron('update trending links', '45 * * * *', updateTrendingLinks);
|
||||||
Deno.cron(
|
|
||||||
'update trending zapped events',
|
|
||||||
'7 * * * *',
|
|
||||||
() => updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']),
|
|
||||||
);
|
|
||||||
Deno.cron(
|
|
||||||
'update trending events',
|
|
||||||
'15 * * * *',
|
|
||||||
() => updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']),
|
|
||||||
);
|
|
||||||
Deno.cron(
|
|
||||||
'update trending hashtags',
|
|
||||||
'30 * * * *',
|
|
||||||
() => updateTrendingTags('#t', 't', [1], 20),
|
|
||||||
);
|
|
||||||
Deno.cron(
|
|
||||||
'update trending links',
|
|
||||||
'45 * * * *',
|
|
||||||
() => updateTrendingTags('#r', 'r', [1], 20),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ interface EventStatsRow {
|
||||||
reactions_count: number;
|
reactions_count: number;
|
||||||
quotes_count: number;
|
quotes_count: number;
|
||||||
reactions: string;
|
reactions: string;
|
||||||
|
zaps_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
|
|
|
||||||
12
src/db/migrations/025_event_stats_add_zap_count.ts
Normal file
12
src/db/migrations/025_event_stats_add_zap_count.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('event_stats')
|
||||||
|
.addColumn('zaps_amount', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.alterTable('event_stats').dropColumn('zaps_amount').execute();
|
||||||
|
}
|
||||||
14
src/db/migrations/026_tags_name_index.ts
Normal file
14
src/db/migrations/026_tags_name_index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_tags_name')
|
||||||
|
.on('nostr_tags')
|
||||||
|
.column('name')
|
||||||
|
.ifNotExists()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropIndex('idx_tags_name').ifExists().execute();
|
||||||
|
}
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { NostrFilter } from '@nostrify/nostrify';
|
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
|
||||||
import * as pipeline from '@/pipeline.ts';
|
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
|
|
||||||
const debug = Debug('ditto:users');
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
pubkey: string;
|
|
||||||
inserted_at: Date;
|
|
||||||
admin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUserEvent(user: User) {
|
|
||||||
const { origin, host } = Conf.url;
|
|
||||||
const signer = new AdminSigner();
|
|
||||||
|
|
||||||
return signer.signEvent({
|
|
||||||
kind: 30361,
|
|
||||||
tags: [
|
|
||||||
['d', user.pubkey],
|
|
||||||
['role', user.admin ? 'admin' : 'user'],
|
|
||||||
['origin', origin],
|
|
||||||
// NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
|
|
||||||
['alt', `User's account was updated by the admins of ${host}`],
|
|
||||||
],
|
|
||||||
content: '',
|
|
||||||
created_at: Math.floor(user.inserted_at.getTime() / 1000),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds a user to the database. */
|
|
||||||
async function insertUser(user: User) {
|
|
||||||
debug('insertUser', JSON.stringify(user));
|
|
||||||
const event = await buildUserEvent(user);
|
|
||||||
return pipeline.handleEvent(event, AbortSignal.timeout(1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a single user based on one or more properties.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* await findUser({ username: 'alex' });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async function findUser(user: Partial<User>, signal?: AbortSignal): Promise<User | undefined> {
|
|
||||||
const filter: NostrFilter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 };
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(user)) {
|
|
||||||
switch (key) {
|
|
||||||
case 'pubkey':
|
|
||||||
filter['#d'] = [String(value)];
|
|
||||||
break;
|
|
||||||
case 'admin':
|
|
||||||
filter['#role'] = [value ? 'admin' : 'user'];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = await Storages.db();
|
|
||||||
const [event] = await store.query([filter], { signal });
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
return {
|
|
||||||
pubkey: event.tags.find(([name]) => name === 'd')?.[1]!,
|
|
||||||
inserted_at: new Date(event.created_at * 1000),
|
|
||||||
admin: event.tags.find(([name]) => name === 'role')?.[1] === 'admin',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { buildUserEvent, findUser, insertUser, type User };
|
|
||||||
58
src/entities/MastodonAccount.ts
Normal file
58
src/entities/MastodonAccount.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/** Mastodon account entity, including supported extensions from Pleroma, etc. */
|
||||||
|
export interface MastodonAccount {
|
||||||
|
id: string;
|
||||||
|
acct: string;
|
||||||
|
avatar: string;
|
||||||
|
avatar_static: string;
|
||||||
|
bot: boolean;
|
||||||
|
created_at: string;
|
||||||
|
discoverable: boolean;
|
||||||
|
display_name: string;
|
||||||
|
emojis: {
|
||||||
|
shortcode: string;
|
||||||
|
static_url: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
fields: unknown[];
|
||||||
|
follow_requests_count: number;
|
||||||
|
followers_count: number;
|
||||||
|
following_count: number;
|
||||||
|
fqn: string;
|
||||||
|
header: string;
|
||||||
|
header_static: string;
|
||||||
|
last_status_at: string | null;
|
||||||
|
locked: boolean;
|
||||||
|
note: string;
|
||||||
|
roles: unknown[];
|
||||||
|
source?: {
|
||||||
|
fields: unknown[];
|
||||||
|
language: string;
|
||||||
|
note: string;
|
||||||
|
privacy: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
follow_requests_count: number;
|
||||||
|
nostr: {
|
||||||
|
nip05?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
statuses_count: number;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
ditto: {
|
||||||
|
accepts_zaps: boolean;
|
||||||
|
};
|
||||||
|
pleroma: {
|
||||||
|
deactivated: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_moderator: boolean;
|
||||||
|
is_suggested: boolean;
|
||||||
|
is_local: boolean;
|
||||||
|
settings_store: unknown;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
nostr: {
|
||||||
|
pubkey: string;
|
||||||
|
lud16?: string;
|
||||||
|
};
|
||||||
|
website?: string;
|
||||||
|
}
|
||||||
6
src/entities/MastodonMention.ts
Normal file
6
src/entities/MastodonMention.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface MastodonMention {
|
||||||
|
acct: string;
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
42
src/entities/MastodonStatus.ts
Normal file
42
src/entities/MastodonStatus.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||||
|
import { PreviewCard } from '@/entities/PreviewCard.ts';
|
||||||
|
|
||||||
|
export interface MastodonStatus {
|
||||||
|
id: string;
|
||||||
|
account: MastodonAccount;
|
||||||
|
card: PreviewCard | null;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
in_reply_to_id: string | null;
|
||||||
|
in_reply_to_account_id: string | null;
|
||||||
|
sensitive: boolean;
|
||||||
|
spoiler_text: string;
|
||||||
|
visibility: string;
|
||||||
|
language: string | null;
|
||||||
|
replies_count: number;
|
||||||
|
reblogs_count: number;
|
||||||
|
favourites_count: number;
|
||||||
|
zaps_amount: number;
|
||||||
|
favourited: boolean;
|
||||||
|
reblogged: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
bookmarked: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
reblog: MastodonStatus | null;
|
||||||
|
application: unknown;
|
||||||
|
media_attachments: unknown[];
|
||||||
|
mentions: unknown[];
|
||||||
|
tags: unknown[];
|
||||||
|
emojis: unknown[];
|
||||||
|
poll: unknown;
|
||||||
|
quote?: MastodonStatus | null;
|
||||||
|
quote_id: string | null;
|
||||||
|
uri: string;
|
||||||
|
url: string;
|
||||||
|
zapped: boolean;
|
||||||
|
pleroma: {
|
||||||
|
emoji_reactions: { name: string; count: number; me: boolean }[];
|
||||||
|
expires_at?: string;
|
||||||
|
quotes_count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/entities/PreviewCard.ts
Normal file
16
src/entities/PreviewCard.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface PreviewCard {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: 'link' | 'photo' | 'video' | 'rich';
|
||||||
|
author_name: string;
|
||||||
|
author_url: string;
|
||||||
|
provider_name: string;
|
||||||
|
provider_url: string;
|
||||||
|
html: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
image: string | null;
|
||||||
|
embed_url: string;
|
||||||
|
blurhash: string | null;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ export interface EventStats {
|
||||||
reposts_count: number;
|
reposts_count: number;
|
||||||
quotes_count: number;
|
quotes_count: number;
|
||||||
reactions: Record<string, number>;
|
reactions: Record<string, number>;
|
||||||
|
zaps_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Internal Event representation used by Ditto, including extra keys. */
|
/** Internal Event representation used by Ditto, including extra keys. */
|
||||||
|
|
@ -21,7 +22,6 @@ export interface DittoEvent extends NostrEvent {
|
||||||
author_domain?: string;
|
author_domain?: string;
|
||||||
author_stats?: AuthorStats;
|
author_stats?: AuthorStats;
|
||||||
event_stats?: EventStats;
|
event_stats?: EventStats;
|
||||||
d_author?: DittoEvent;
|
|
||||||
user?: DittoEvent;
|
user?: DittoEvent;
|
||||||
repost?: DittoEvent;
|
repost?: DittoEvent;
|
||||||
quote?: DittoEvent;
|
quote?: DittoEvent;
|
||||||
|
|
@ -35,4 +35,6 @@ export interface DittoEvent extends NostrEvent {
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/56.md
|
* https://github.com/nostr-protocol/nips/blob/master/56.md
|
||||||
*/
|
*/
|
||||||
reported_notes?: DittoEvent[];
|
reported_notes?: DittoEvent[];
|
||||||
|
/** Admin event relationship. */
|
||||||
|
info?: DittoEvent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { HTTPException } from 'hono';
|
import { HTTPException } from 'hono';
|
||||||
|
|
||||||
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
||||||
import { findUser, User } from '@/db/users.ts';
|
|
||||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||||
|
import { Storages } from '@/storages.ts';
|
||||||
import { localRequest } from '@/utils/api.ts';
|
import { localRequest } from '@/utils/api.ts';
|
||||||
import {
|
import {
|
||||||
buildAuthEventTemplate,
|
buildAuthEventTemplate,
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
type ParseAuthRequestOpts,
|
type ParseAuthRequestOpts,
|
||||||
validateAuthEvent,
|
validateAuthEvent,
|
||||||
} from '@/utils/nip98.ts';
|
} from '@/utils/nip98.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-98 auth.
|
* NIP-98 auth.
|
||||||
|
|
@ -35,7 +36,14 @@ type UserRole = 'user' | 'admin';
|
||||||
/** Require the user to prove their role before invoking the controller. */
|
/** Require the user to prove their role before invoking the controller. */
|
||||||
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
|
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
|
||||||
return withProof(async (_c, proof, next) => {
|
return withProof(async (_c, proof, next) => {
|
||||||
const user = await findUser({ pubkey: proof.pubkey });
|
const store = await Storages.db();
|
||||||
|
|
||||||
|
const [user] = await store.query([{
|
||||||
|
kinds: [30382],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
|
'#d': [proof.pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}]);
|
||||||
|
|
||||||
if (user && matchesRole(user, role)) {
|
if (user && matchesRole(user, role)) {
|
||||||
await next();
|
await next();
|
||||||
|
|
@ -53,15 +61,8 @@ function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the user fulfills the role. */
|
/** Check whether the user fulfills the role. */
|
||||||
function matchesRole(user: User, role: UserRole): boolean {
|
function matchesRole(user: NostrEvent, role: UserRole): boolean {
|
||||||
switch (role) {
|
return user.tags.some(([tag, value]) => tag === 'n' && value === role);
|
||||||
case 'user':
|
|
||||||
return true;
|
|
||||||
case 'admin':
|
|
||||||
return user.admin;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HOC to obtain proof in middleware. */
|
/** HOC to obtain proof in middleware. */
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,20 @@ export const uploaderMiddleware: AppMiddleware = async (c, next) => {
|
||||||
|
|
||||||
switch (Conf.uploader) {
|
switch (Conf.uploader) {
|
||||||
case 's3':
|
case 's3':
|
||||||
c.set('uploader', new S3Uploader(Conf.s3));
|
c.set(
|
||||||
|
'uploader',
|
||||||
|
new S3Uploader({
|
||||||
|
accessKey: Conf.s3.accessKey,
|
||||||
|
bucket: Conf.s3.bucket,
|
||||||
|
endPoint: Conf.s3.endPoint!,
|
||||||
|
pathStyle: Conf.s3.pathStyle,
|
||||||
|
port: Conf.s3.port,
|
||||||
|
region: Conf.s3.region!,
|
||||||
|
secretKey: Conf.s3.secretKey,
|
||||||
|
sessionToken: Conf.s3.sessionToken,
|
||||||
|
useSSL: Conf.s3.useSSL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 'ipfs':
|
case 'ipfs':
|
||||||
c.set('uploader', new IPFSUploader({ baseUrl: Conf.mediaDomain, apiUrl: Conf.ipfs.apiUrl, fetch: fetchWorker }));
|
c.set('uploader', new IPFSUploader({ baseUrl: Conf.mediaDomain, apiUrl: Conf.ipfs.apiUrl, fetch: fetchWorker }));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { PipePolicy } from '@nostrify/nostrify/policies';
|
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
import Debug from '@soapbox/stickynotes/debug';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
@ -8,8 +7,8 @@ import { Conf } from '@/config.ts';
|
||||||
import { DittoDB } from '@/db/DittoDB.ts';
|
import { DittoDB } from '@/db/DittoDB.ts';
|
||||||
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
|
|
||||||
import { RelayError } from '@/RelayError.ts';
|
import { RelayError } from '@/RelayError.ts';
|
||||||
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { eventAge, parseNip05, Time } from '@/utils.ts';
|
import { eventAge, parseNip05, Time } from '@/utils.ts';
|
||||||
|
|
@ -35,6 +34,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
|
||||||
}
|
}
|
||||||
if (!(await verifyEventWorker(event))) return;
|
if (!(await verifyEventWorker(event))) return;
|
||||||
if (encounterEvent(event)) return;
|
if (encounterEvent(event)) return;
|
||||||
|
if (await existsInDB(event)) return;
|
||||||
debug(`NostrEvent<${event.kind}> ${event.id}`);
|
debug(`NostrEvent<${event.kind}> ${event.id}`);
|
||||||
|
|
||||||
if (event.kind !== 24133) {
|
if (event.kind !== 24133) {
|
||||||
|
|
@ -43,9 +43,19 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
|
||||||
|
|
||||||
await hydrateEvent(event, signal);
|
await hydrateEvent(event, signal);
|
||||||
|
|
||||||
|
const n = getTagSet(event.user?.tags ?? [], 'n');
|
||||||
|
|
||||||
|
if (n.has('disabled')) {
|
||||||
|
throw new RelayError('blocked', 'user is disabled');
|
||||||
|
}
|
||||||
|
if (n.has('suspended')) {
|
||||||
|
throw new RelayError('blocked', 'user is suspended');
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
storeEvent(event, signal),
|
storeEvent(event, signal),
|
||||||
parseMetadata(event, signal),
|
parseMetadata(event, signal),
|
||||||
|
generateSetEvents(event),
|
||||||
processMedia(event),
|
processMedia(event),
|
||||||
streamOut(event),
|
streamOut(event),
|
||||||
]);
|
]);
|
||||||
|
|
@ -54,13 +64,8 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
|
||||||
async function policyFilter(event: NostrEvent): Promise<void> {
|
async function policyFilter(event: NostrEvent): Promise<void> {
|
||||||
const debug = Debug('ditto:policy');
|
const debug = Debug('ditto:policy');
|
||||||
|
|
||||||
const policy = new PipePolicy([
|
|
||||||
new MuteListPolicy(Conf.pubkey, await Storages.admin()),
|
|
||||||
policyWorker,
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await policy.call(event);
|
const result = await policyWorker.call(event);
|
||||||
debug(JSON.stringify(result));
|
debug(JSON.stringify(result));
|
||||||
RelayError.assert(result);
|
RelayError.assert(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -84,6 +89,13 @@ function encounterEvent(event: NostrEvent): boolean {
|
||||||
return encountered;
|
return encountered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check if the event already exists in the database. */
|
||||||
|
async function existsInDB(event: DittoEvent): Promise<boolean> {
|
||||||
|
const store = await Storages.db();
|
||||||
|
const events = await store.query([{ ids: [event.id], limit: 1 }]);
|
||||||
|
return events.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Hydrate the event with the user, if applicable. */
|
/** Hydrate the event with the user, if applicable. */
|
||||||
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
||||||
await hydrateEvents({ events: [event], store: await Storages.db(), signal });
|
await hydrateEvents({ events: [event], store: await Storages.db(), signal });
|
||||||
|
|
@ -167,4 +179,46 @@ async function streamOut(event: NostrEvent): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateSetEvents(event: NostrEvent): Promise<void> {
|
||||||
|
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);
|
||||||
|
|
||||||
|
if (event.kind === 1984 && tagsAdmin) {
|
||||||
|
const signer = new AdminSigner();
|
||||||
|
|
||||||
|
const rel = await signer.signEvent({
|
||||||
|
kind: 30383,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['d', event.id],
|
||||||
|
['p', event.pubkey],
|
||||||
|
['k', '1984'],
|
||||||
|
['n', 'open'],
|
||||||
|
...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
|
||||||
|
...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleEvent(rel, AbortSignal.timeout(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 3036 && tagsAdmin) {
|
||||||
|
const signer = new AdminSigner();
|
||||||
|
|
||||||
|
const rel = await signer.signEvent({
|
||||||
|
kind: 30383,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['d', event.id],
|
||||||
|
['p', event.pubkey],
|
||||||
|
['k', '3036'],
|
||||||
|
['n', 'pending'],
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleEvent(rel, AbortSignal.timeout(1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { handleEvent };
|
export { handleEvent };
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
// deno-lint-ignore-file require-await
|
// deno-lint-ignore-file require-await
|
||||||
|
import { NPool, NRelay1 } from '@nostrify/nostrify';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { DittoDB } from '@/db/DittoDB.ts';
|
import { DittoDB } from '@/db/DittoDB.ts';
|
||||||
|
import { AdminStore } from '@/storages/AdminStore.ts';
|
||||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||||
import { SearchStore } from '@/storages/search-store.ts';
|
import { SearchStore } from '@/storages/search-store.ts';
|
||||||
import { InternalRelay } from '@/storages/InternalRelay.ts';
|
import { InternalRelay } from '@/storages/InternalRelay.ts';
|
||||||
import { UserStore } from '@/storages/UserStore.ts';
|
|
||||||
import { NPool, NRelay1 } from '@nostrify/nostrify';
|
|
||||||
|
|
||||||
export class Storages {
|
export class Storages {
|
||||||
private static _db: Promise<EventsDB> | undefined;
|
private static _db: Promise<EventsDB> | undefined;
|
||||||
private static _admin: Promise<UserStore> | undefined;
|
private static _admin: Promise<AdminStore> | undefined;
|
||||||
private static _client: Promise<NPool> | undefined;
|
private static _client: Promise<NPool> | undefined;
|
||||||
private static _pubsub: Promise<InternalRelay> | undefined;
|
private static _pubsub: Promise<InternalRelay> | undefined;
|
||||||
private static _search: Promise<SearchStore> | undefined;
|
private static _search: Promise<SearchStore> | undefined;
|
||||||
|
|
@ -26,9 +27,9 @@ export class Storages {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin user storage. */
|
/** Admin user storage. */
|
||||||
public static async admin(): Promise<UserStore> {
|
public static async admin(): Promise<AdminStore> {
|
||||||
if (!this._admin) {
|
if (!this._admin) {
|
||||||
this._admin = Promise.resolve(new UserStore(Conf.pubkey, await this.db()));
|
this._admin = Promise.resolve(new AdminStore(await this.db()));
|
||||||
}
|
}
|
||||||
return this._admin;
|
return this._admin;
|
||||||
}
|
}
|
||||||
|
|
@ -62,14 +63,13 @@ export class Storages {
|
||||||
|
|
||||||
console.log(`pool: connecting to ${activeRelays.length} relays.`);
|
console.log(`pool: connecting to ${activeRelays.length} relays.`);
|
||||||
|
|
||||||
const pool = new NPool({
|
return new NPool({
|
||||||
open(url: string) {
|
open(url: string) {
|
||||||
return new NRelay1(url);
|
return new NRelay1(url);
|
||||||
},
|
},
|
||||||
reqRelays: async () => activeRelays,
|
reqRelays: async () => activeRelays,
|
||||||
eventRelays: async () => activeRelays,
|
eventRelays: async () => activeRelays,
|
||||||
});
|
});
|
||||||
return pool;
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
return this._client;
|
return this._client;
|
||||||
|
|
|
||||||
41
src/storages/AdminStore.ts
Normal file
41
src/storages/AdminStore.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
|
|
||||||
|
/** A store that prevents banned users from being displayed. */
|
||||||
|
export class AdminStore implements NStore {
|
||||||
|
constructor(private store: NStore) {}
|
||||||
|
|
||||||
|
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
|
||||||
|
return await this.store.event(event, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
|
||||||
|
const events = await this.store.query(filters, opts);
|
||||||
|
const pubkeys = new Set(events.map((event) => event.pubkey));
|
||||||
|
|
||||||
|
const users = await this.store.query([{
|
||||||
|
kinds: [30382],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
|
'#d': [...pubkeys],
|
||||||
|
limit: pubkeys.size,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return events.filter((event) => {
|
||||||
|
const user = users.find(
|
||||||
|
({ kind, pubkey, tags }) =>
|
||||||
|
kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const n = getTagSet(user?.tags ?? [], 'n');
|
||||||
|
|
||||||
|
if (n.has('disabled') || n.has('suspended')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { NDatabase, NIP50, NKinds, NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
|
import { NDatabase, NIP50, NKinds, NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||||
import { Stickynotes } from '@soapbox/stickynotes';
|
import { Stickynotes } from '@soapbox/stickynotes';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { nip27 } from 'nostr-tools';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { DittoTables } from '@/db/DittoTables.ts';
|
import { DittoTables } from '@/db/DittoTables.ts';
|
||||||
|
|
@ -11,7 +12,6 @@ import { RelayError } from '@/RelayError.ts';
|
||||||
import { purifyEvent } from '@/storages/hydrate.ts';
|
import { purifyEvent } from '@/storages/hydrate.ts';
|
||||||
import { isNostrId, isURL } from '@/utils.ts';
|
import { isNostrId, isURL } from '@/utils.ts';
|
||||||
import { abortError } from '@/utils/abort.ts';
|
import { abortError } from '@/utils/abort.ts';
|
||||||
import { getTagSet } from '@/utils/tags.ts';
|
|
||||||
|
|
||||||
/** Function to decide whether or not to index a tag. */
|
/** Function to decide whether or not to index a tag. */
|
||||||
type TagCondition = ({ event, count, value }: {
|
type TagCondition = ({ event, count, value }: {
|
||||||
|
|
@ -27,20 +27,19 @@ class EventsDB implements NStore {
|
||||||
|
|
||||||
/** Conditions for when to index certain tags. */
|
/** Conditions for when to index certain tags. */
|
||||||
static tagConditions: Record<string, TagCondition> = {
|
static tagConditions: Record<string, TagCondition> = {
|
||||||
|
'a': ({ count }) => count < 15,
|
||||||
'd': ({ event, count }) => count === 0 && NKinds.parameterizedReplaceable(event.kind),
|
'd': ({ event, count }) => count === 0 && NKinds.parameterizedReplaceable(event.kind),
|
||||||
'e': ({ event, count, value }) => ((event.kind === 10003) || count < 15) && isNostrId(value),
|
'e': ({ event, count, value }) => ((event.kind === 10003) || count < 15) && isNostrId(value),
|
||||||
|
'k': ({ count, value }) => count === 0 && Number.isInteger(Number(value)),
|
||||||
'L': ({ event, count }) => event.kind === 1985 || count === 0,
|
'L': ({ event, count }) => event.kind === 1985 || count === 0,
|
||||||
'l': ({ event, count }) => event.kind === 1985 || count === 0,
|
'l': ({ event, count }) => event.kind === 1985 || count === 0,
|
||||||
'media': ({ count, value }) => (count < 4) && isURL(value),
|
|
||||||
'n': ({ count, value }) => count < 50 && value.length < 50,
|
'n': ({ count, value }) => count < 50 && value.length < 50,
|
||||||
'P': ({ count, value }) => count === 0 && isNostrId(value),
|
'P': ({ count, value }) => count === 0 && isNostrId(value),
|
||||||
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
||||||
'proxy': ({ count, value }) => count === 0 && isURL(value),
|
'proxy': ({ count, value }) => count === 0 && isURL(value),
|
||||||
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
|
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
|
||||||
'r': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 3) && isURL(value),
|
'r': ({ event, count }) => (event.kind === 1985 ? count < 20 : count < 3),
|
||||||
't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
|
't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
|
||||||
'name': ({ event, count }) => event.kind === 30361 && count === 0,
|
|
||||||
'role': ({ event, count }) => event.kind === 30361 && count === 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private kysely: Kysely<DittoTables>) {
|
constructor(private kysely: Kysely<DittoTables>) {
|
||||||
|
|
@ -77,17 +76,62 @@ class EventsDB implements NStore {
|
||||||
|
|
||||||
/** Check if an event has been deleted by the admin. */
|
/** Check if an event has been deleted by the admin. */
|
||||||
private async isDeletedAdmin(event: NostrEvent): Promise<boolean> {
|
private async isDeletedAdmin(event: NostrEvent): Promise<boolean> {
|
||||||
const [deletion] = await this.query([
|
const filters: NostrFilter[] = [
|
||||||
{ kinds: [5], authors: [Conf.pubkey], '#e': [event.id], limit: 1 },
|
{ kinds: [5], authors: [Conf.pubkey], '#e': [event.id], limit: 1 },
|
||||||
]);
|
];
|
||||||
return !!deletion;
|
|
||||||
|
if (NKinds.replaceable(event.kind) || NKinds.parameterizedReplaceable(event.kind)) {
|
||||||
|
const d = event.tags.find(([tag]) => tag === 'd')?.[1] ?? '';
|
||||||
|
|
||||||
|
filters.push({
|
||||||
|
kinds: [5],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
|
'#a': [`${event.kind}:${event.pubkey}:${d}`],
|
||||||
|
since: event.created_at,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await this.query(filters);
|
||||||
|
return events.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The DITTO_NSEC can delete any event from the database. NDatabase already handles user deletions. */
|
/** The DITTO_NSEC can delete any event from the database. NDatabase already handles user deletions. */
|
||||||
private async deleteEventsAdmin(event: NostrEvent): Promise<void> {
|
private async deleteEventsAdmin(event: NostrEvent): Promise<void> {
|
||||||
if (event.kind === 5 && event.pubkey === Conf.pubkey) {
|
if (event.kind === 5 && event.pubkey === Conf.pubkey) {
|
||||||
const ids = getTagSet(event.tags, 'e');
|
const ids = new Set(event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value));
|
||||||
await this.remove([{ ids: [...ids] }]);
|
const addrs = new Set(event.tags.filter(([name]) => name === 'a').map(([_name, value]) => value));
|
||||||
|
|
||||||
|
const filters: NostrFilter[] = [];
|
||||||
|
|
||||||
|
if (ids.size) {
|
||||||
|
filters.push({ ids: [...ids] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const addr of addrs) {
|
||||||
|
const [k, pubkey, d] = addr.split(':');
|
||||||
|
const kind = Number(k);
|
||||||
|
|
||||||
|
if (!(Number.isInteger(kind) && kind >= 0)) continue;
|
||||||
|
if (!isNostrId(pubkey)) continue;
|
||||||
|
if (d === undefined) continue;
|
||||||
|
|
||||||
|
const filter: NostrFilter = {
|
||||||
|
kinds: [kind],
|
||||||
|
authors: [pubkey],
|
||||||
|
until: event.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (d) {
|
||||||
|
filter['#d'] = [d];
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.length) {
|
||||||
|
await this.remove(filters);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,9 +221,11 @@ class EventsDB implements NStore {
|
||||||
case 0:
|
case 0:
|
||||||
return EventsDB.buildUserSearchContent(event);
|
return EventsDB.buildUserSearchContent(event);
|
||||||
case 1:
|
case 1:
|
||||||
return event.content;
|
return nip27.replaceAll(event.content, () => '');
|
||||||
case 30009:
|
case 30009:
|
||||||
return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt'));
|
return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt'));
|
||||||
|
case 30360:
|
||||||
|
return event.tags.find(([name]) => name === 'd')?.[1] || '';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const event of await gatherInfo({ events: cache, store, signal })) {
|
||||||
|
cache.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
for (const event of await gatherReportedProfiles({ events: cache, store, signal })) {
|
for (const event of await gatherReportedProfiles({ events: cache, store, signal })) {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +86,8 @@ export function assembleEvents(
|
||||||
|
|
||||||
for (const event of a) {
|
for (const event of a) {
|
||||||
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e));
|
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e));
|
||||||
event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e));
|
event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e));
|
||||||
|
event.info = b.find((e) => matchFilter({ kinds: [30383], authors: [admin], '#d': [event.id] }, e));
|
||||||
|
|
||||||
if (event.kind === 1) {
|
if (event.kind === 1) {
|
||||||
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
|
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
|
||||||
|
|
@ -106,21 +111,22 @@ export function assembleEvents(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === 1984) {
|
if (event.kind === 1984) {
|
||||||
const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1];
|
const pubkey = event.tags.find(([name]) => name === 'p')?.[1];
|
||||||
if (targetAccountId) {
|
if (pubkey) {
|
||||||
event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [targetAccountId] }, e));
|
event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [pubkey] }, e));
|
||||||
}
|
}
|
||||||
const reportedEvents: DittoEvent[] = [];
|
|
||||||
|
|
||||||
const status_ids = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]);
|
const reportedEvents: DittoEvent[] = [];
|
||||||
if (status_ids.length > 0) {
|
const ids = event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value);
|
||||||
for (const id of status_ids) {
|
|
||||||
const reportedEvent = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
|
for (const id of ids) {
|
||||||
if (reportedEvent) reportedEvents.push(reportedEvent);
|
const reported = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
|
||||||
|
if (reported) {
|
||||||
|
reportedEvents.push(reported);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
event.reported_notes = reportedEvents;
|
event.reported_notes = reportedEvents;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey);
|
event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey);
|
||||||
event.event_stats = eventStats.find((stats) => stats.event_id === event.id);
|
event.event_stats = eventStats.find((stats) => stats.event_id === event.id);
|
||||||
|
|
@ -200,8 +206,32 @@ function gatherAuthors({ events, store, signal }: HydrateOpts): Promise<DittoEve
|
||||||
function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const pubkeys = new Set(events.map((event) => event.pubkey));
|
const pubkeys = new Set(events.map((event) => event.pubkey));
|
||||||
|
|
||||||
|
if (!pubkeys.size) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
return store.query(
|
return store.query(
|
||||||
[{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
|
[{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect info events from the events. */
|
||||||
|
function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.kind === 1984 || event.kind === 3036) {
|
||||||
|
ids.add(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ids.size) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.query(
|
||||||
|
[{ kinds: [30383], authors: [Conf.pubkey], '#d': [...ids], limit: ids.size }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +330,7 @@ async function gatherEventStats(events: DittoEvent[]): Promise<DittoTables['even
|
||||||
reactions_count: Math.max(0, row.reactions_count),
|
reactions_count: Math.max(0, row.reactions_count),
|
||||||
quotes_count: Math.max(0, row.quotes_count),
|
quotes_count: Math.max(0, row.quotes_count),
|
||||||
reactions: row.reactions,
|
reactions: row.reactions,
|
||||||
|
zaps_amount: Math.max(0, row.zaps_amount),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
127
src/trends.ts
Normal file
127
src/trends.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { NostrFilter } from '@nostrify/nostrify';
|
||||||
|
import { Stickynotes } from '@soapbox/stickynotes';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { DittoDB } from '@/db/DittoDB.ts';
|
||||||
|
import { DittoTables } from '@/db/DittoTables.ts';
|
||||||
|
import { handleEvent } from '@/pipeline.ts';
|
||||||
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
|
const console = new Stickynotes('ditto:trends');
|
||||||
|
|
||||||
|
/** Get trending tag values for a given tag in the given time frame. */
|
||||||
|
export async function getTrendingTagValues(
|
||||||
|
/** Kysely instance to execute queries on. */
|
||||||
|
kysely: Kysely<DittoTables>,
|
||||||
|
/** Tag name to filter by, eg `t` or `r`. */
|
||||||
|
tagNames: string[],
|
||||||
|
/** Filter of eligible events. */
|
||||||
|
filter: NostrFilter,
|
||||||
|
): Promise<{ value: string; authors: number; uses: number }[]> {
|
||||||
|
let query = kysely
|
||||||
|
.selectFrom('nostr_tags')
|
||||||
|
.innerJoin('nostr_events', 'nostr_events.id', 'nostr_tags.event_id')
|
||||||
|
.select(({ fn }) => [
|
||||||
|
'nostr_tags.value',
|
||||||
|
fn.agg<number>('count', ['nostr_events.pubkey']).distinct().as('authors'),
|
||||||
|
fn.countAll<number>().as('uses'),
|
||||||
|
])
|
||||||
|
.where('nostr_tags.name', 'in', tagNames)
|
||||||
|
.groupBy('nostr_tags.value')
|
||||||
|
.orderBy((c) => c.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc');
|
||||||
|
|
||||||
|
if (filter.kinds) {
|
||||||
|
query = query.where('nostr_events.kind', 'in', filter.kinds);
|
||||||
|
}
|
||||||
|
if (typeof filter.since === 'number') {
|
||||||
|
query = query.where('nostr_events.created_at', '>=', filter.since);
|
||||||
|
}
|
||||||
|
if (typeof filter.until === 'number') {
|
||||||
|
query = query.where('nostr_events.created_at', '<=', filter.until);
|
||||||
|
}
|
||||||
|
if (typeof filter.limit === 'number') {
|
||||||
|
query = query.limit(filter.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query.execute();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
value: row.value,
|
||||||
|
authors: Number(row.authors),
|
||||||
|
uses: Number(row.uses),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get trending tags and publish an event with them. */
|
||||||
|
export async function updateTrendingTags(
|
||||||
|
l: string,
|
||||||
|
tagName: string,
|
||||||
|
kinds: number[],
|
||||||
|
limit: number,
|
||||||
|
extra = '',
|
||||||
|
aliases?: string[],
|
||||||
|
) {
|
||||||
|
console.info(`Updating trending ${l}...`);
|
||||||
|
const kysely = await DittoDB.getInstance();
|
||||||
|
const signal = AbortSignal.timeout(1000);
|
||||||
|
|
||||||
|
const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const tagNames = aliases ? [tagName, ...aliases] : [tagName];
|
||||||
|
|
||||||
|
const trends = await getTrendingTagValues(kysely, tagNames, {
|
||||||
|
kinds,
|
||||||
|
since: yesterday,
|
||||||
|
until: now,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!trends.length) {
|
||||||
|
console.info(`No trending ${l} found. Skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = new AdminSigner();
|
||||||
|
|
||||||
|
const label = await signer.signEvent({
|
||||||
|
kind: 1985,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['L', 'pub.ditto.trends'],
|
||||||
|
['l', l, 'pub.ditto.trends'],
|
||||||
|
...trends.map(({ value, authors, uses }) => [tagName, value, extra, authors.toString(), uses.toString()]),
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleEvent(label, signal);
|
||||||
|
console.info(`Trending ${l} updated.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update trending pubkeys. */
|
||||||
|
export function updateTrendingPubkeys(): Promise<void> {
|
||||||
|
return updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, Conf.relay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update trending zapped events. */
|
||||||
|
export function updateTrendingZappedEvents(): Promise<void> {
|
||||||
|
return updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update trending events. */
|
||||||
|
export function updateTrendingEvents(): Promise<void> {
|
||||||
|
return updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update trending hashtags. */
|
||||||
|
export function updateTrendingHashtags(): Promise<void> {
|
||||||
|
return updateTrendingTags('#t', 't', [1], 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update trending links. */
|
||||||
|
export function updateTrendingLinks(): Promise<void> {
|
||||||
|
return updateTrendingTags('#r', 'r', [1], 20);
|
||||||
|
}
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { NostrFilter } from '@nostrify/nostrify';
|
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
|
|
||||||
import { DittoTables } from '@/db/DittoTables.ts';
|
|
||||||
|
|
||||||
/** Get trending tag values for a given tag in the given time frame. */
|
|
||||||
export async function getTrendingTagValues(
|
|
||||||
/** Kysely instance to execute queries on. */
|
|
||||||
kysely: Kysely<DittoTables>,
|
|
||||||
/** Tag name to filter by, eg `t` or `r`. */
|
|
||||||
tagNames: string[],
|
|
||||||
/** Filter of eligible events. */
|
|
||||||
filter: NostrFilter,
|
|
||||||
): Promise<{ value: string; authors: number; uses: number }[]> {
|
|
||||||
let query = kysely
|
|
||||||
.selectFrom('nostr_tags')
|
|
||||||
.innerJoin('nostr_events', 'nostr_events.id', 'nostr_tags.event_id')
|
|
||||||
.select(({ fn }) => [
|
|
||||||
'nostr_tags.value',
|
|
||||||
fn.agg<number>('count', ['nostr_events.pubkey']).distinct().as('authors'),
|
|
||||||
fn.countAll<number>().as('uses'),
|
|
||||||
])
|
|
||||||
.where('nostr_tags.name', 'in', tagNames)
|
|
||||||
.groupBy('nostr_tags.value')
|
|
||||||
.orderBy((c) => c.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc');
|
|
||||||
|
|
||||||
if (filter.kinds) {
|
|
||||||
query = query.where('nostr_events.kind', 'in', filter.kinds);
|
|
||||||
}
|
|
||||||
if (typeof filter.since === 'number') {
|
|
||||||
query = query.where('nostr_events.created_at', '>=', filter.since);
|
|
||||||
}
|
|
||||||
if (typeof filter.until === 'number') {
|
|
||||||
query = query.where('nostr_events.created_at', '<=', filter.until);
|
|
||||||
}
|
|
||||||
if (typeof filter.limit === 'number') {
|
|
||||||
query = query.limit(filter.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await query.execute();
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
|
||||||
value: row.value,
|
|
||||||
authors: Number(row.authors),
|
|
||||||
uses: Number(row.uses),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -108,6 +108,44 @@ async function updateAdminEvent<E extends EventStub>(
|
||||||
return createAdminEvent(fn(prev), c);
|
return createAdminEvent(fn(prev), c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUser(pubkey: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
|
||||||
|
return updateNames(30382, pubkey, n, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEventInfo(id: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
|
||||||
|
return updateNames(30383, id, n, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNames(k: number, d: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
|
||||||
|
const signer = new AdminSigner();
|
||||||
|
const admin = await signer.getPublicKey();
|
||||||
|
|
||||||
|
return updateAdminEvent(
|
||||||
|
{ kinds: [k], authors: [admin], '#d': [d], limit: 1 },
|
||||||
|
(prev) => {
|
||||||
|
const prevNames = prev?.tags.reduce((acc, [name, value]) => {
|
||||||
|
if (name === 'n') acc[value] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
|
||||||
|
const names = { ...prevNames, ...n };
|
||||||
|
const nTags = Object.entries(names).filter(([, value]) => value).map(([name]) => ['n', name]);
|
||||||
|
const other = prev?.tags.filter(([name]) => !['d', 'n'].includes(name)) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: k,
|
||||||
|
content: prev?.content ?? '',
|
||||||
|
tags: [
|
||||||
|
['d', d],
|
||||||
|
...nTags,
|
||||||
|
...other,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Push the event through the pipeline, rethrowing any RelayError. */
|
/** Push the event through the pipeline, rethrowing any RelayError. */
|
||||||
async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> {
|
async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> {
|
||||||
debug('EVENT', event);
|
debug('EVENT', event);
|
||||||
|
|
@ -266,7 +304,10 @@ export {
|
||||||
type PaginationParams,
|
type PaginationParams,
|
||||||
paginationSchema,
|
paginationSchema,
|
||||||
parseBody,
|
parseBody,
|
||||||
|
updateAdminEvent,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
|
updateEventInfo,
|
||||||
updateListAdminEvent,
|
updateListAdminEvent,
|
||||||
updateListEvent,
|
updateListEvent,
|
||||||
|
updateUser,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,16 +45,15 @@ const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
|
||||||
{ max: 500, ttl: Time.hours(1) },
|
{ max: 500, ttl: Time.hours(1) },
|
||||||
);
|
);
|
||||||
|
|
||||||
async function localNip05Lookup(store: NStore, name: string): Promise<nip19.ProfilePointer | undefined> {
|
async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> {
|
||||||
const [label] = await store.query([{
|
const [grant] = await store.query([{
|
||||||
kinds: [1985],
|
kinds: [30360],
|
||||||
|
'#d': [`${localpart}@${Conf.url.host}`],
|
||||||
authors: [Conf.pubkey],
|
authors: [Conf.pubkey],
|
||||||
'#L': ['nip05'],
|
|
||||||
'#l': [`${name}@${Conf.url.host}`],
|
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
const pubkey = label?.tags.find(([name]) => name === 'p')?.[1];
|
const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1];
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
return { pubkey, relays: [Conf.relay] };
|
return { pubkey, relays: [Conf.relay] };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NostrEvent, NStore } from '@nostrify/nostrify';
|
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||||
import { Kysely, UpdateObject } from 'kysely';
|
import { Kysely, UpdateObject } from 'kysely';
|
||||||
import { SetRequired } from 'type-fest';
|
import { SetRequired } from 'type-fest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { DittoTables } from '@/db/DittoTables.ts';
|
import { DittoTables } from '@/db/DittoTables.ts';
|
||||||
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
|
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
|
||||||
|
|
@ -27,6 +28,8 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp
|
||||||
return handleEvent6(kysely, event, x);
|
return handleEvent6(kysely, event, x);
|
||||||
case 7:
|
case 7:
|
||||||
return handleEvent7(kysely, event, x);
|
return handleEvent7(kysely, event, x);
|
||||||
|
case 9735:
|
||||||
|
return handleEvent9735(kysely, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +135,29 @@ async function handleEvent7(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update stats for kind 9735 event. */
|
||||||
|
async function handleEvent9735(kysely: Kysely<DittoTables>, event: NostrEvent): Promise<void> {
|
||||||
|
// https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-f-validating-zap-receipts
|
||||||
|
const id = event.tags.find(([name]) => name === 'e')?.[1];
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const amountSchema = z.coerce.number().int().nonnegative().catch(0);
|
||||||
|
let amount = 0;
|
||||||
|
try {
|
||||||
|
const zapRequest = n.json().pipe(n.event()).parse(event.tags.find(([name]) => name === 'description')?.[1]);
|
||||||
|
amount = amountSchema.parse(zapRequest.tags.find(([name]) => name === 'amount')?.[1]);
|
||||||
|
if (amount <= 0) return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEventStats(
|
||||||
|
kysely,
|
||||||
|
id,
|
||||||
|
({ zaps_amount }) => ({ zaps_amount: Math.max(0, zaps_amount + amount) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the pubkeys that were added and removed from a follow event. */
|
/** Get the pubkeys that were added and removed from a follow event. */
|
||||||
export function getFollowDiff(
|
export function getFollowDiff(
|
||||||
tags: string[][],
|
tags: string[][],
|
||||||
|
|
@ -219,6 +245,7 @@ export async function updateEventStats(
|
||||||
reposts_count: 0,
|
reposts_count: 0,
|
||||||
reactions_count: 0,
|
reactions_count: 0,
|
||||||
quotes_count: 0,
|
quotes_count: 0,
|
||||||
|
zaps_amount: 0,
|
||||||
reactions: '{}',
|
reactions: '{}',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,12 @@ import Debug from '@soapbox/stickynotes/debug';
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
import { unfurl } from 'unfurl.js';
|
import { unfurl } from 'unfurl.js';
|
||||||
|
|
||||||
|
import { PreviewCard } from '@/entities/PreviewCard.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
import { fetchWorker } from '@/workers/fetch.ts';
|
import { fetchWorker } from '@/workers/fetch.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:unfurl');
|
const debug = Debug('ditto:unfurl');
|
||||||
|
|
||||||
interface PreviewCard {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
type: 'link' | 'photo' | 'video' | 'rich';
|
|
||||||
author_name: string;
|
|
||||||
author_url: string;
|
|
||||||
provider_name: string;
|
|
||||||
provider_url: string;
|
|
||||||
html: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
image: string | null;
|
|
||||||
embed_url: string;
|
|
||||||
blurhash: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unfurlCard(url: string, signal: AbortSignal): Promise<PreviewCard | null> {
|
async function unfurlCard(url: string, signal: AbortSignal): Promise<PreviewCard | null> {
|
||||||
debug(`Unfurling ${url}...`);
|
debug(`Unfurling ${url}...`);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
22
src/views/ditto.ts
Normal file
22
src/views/ditto.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
|
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||||
|
|
||||||
|
/** Renders an Admin::Account entity from a name request event. */
|
||||||
|
export async function renderNameRequest(event: DittoEvent) {
|
||||||
|
const n = getTagSet(event.info?.tags ?? [], 'n');
|
||||||
|
const [username, domain] = event.tags.find(([name]) => name === 'r')?.[1]?.split('@') ?? [];
|
||||||
|
|
||||||
|
const adminAccount = event.author
|
||||||
|
? await renderAdminAccount(event.author)
|
||||||
|
: await renderAdminAccountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...adminAccount,
|
||||||
|
id: event.id,
|
||||||
|
approved: n.has('approved'),
|
||||||
|
username,
|
||||||
|
domain,
|
||||||
|
invite_request: event.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@ import { escape } from 'entities';
|
||||||
import { nip19, UnsignedEvent } from 'nostr-tools';
|
import { nip19, UnsignedEvent } from 'nostr-tools';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { getLnurl } from '@/utils/lnurl.ts';
|
import { getLnurl } from '@/utils/lnurl.ts';
|
||||||
import { nip05Cache } from '@/utils/nip05.ts';
|
import { nip05Cache } from '@/utils/nip05.ts';
|
||||||
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
||||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||||
|
|
||||||
|
|
@ -16,10 +18,17 @@ interface ToAccountOpts {
|
||||||
async function renderAccount(
|
async function renderAccount(
|
||||||
event: Omit<DittoEvent, 'id' | 'sig'>,
|
event: Omit<DittoEvent, 'id' | 'sig'>,
|
||||||
opts: ToAccountOpts = {},
|
opts: ToAccountOpts = {},
|
||||||
) {
|
): Promise<MastodonAccount> {
|
||||||
const { withSource = false } = opts;
|
const { withSource = false } = opts;
|
||||||
const { pubkey } = event;
|
const { pubkey } = event;
|
||||||
|
|
||||||
|
const names = getTagSet(event.user?.tags ?? [], 'n');
|
||||||
|
if (names.has('disabled') || names.has('suspended')) {
|
||||||
|
const account = await accountFromPubkey(pubkey, opts);
|
||||||
|
account.pleroma.deactivated = true;
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
nip05,
|
nip05,
|
||||||
|
|
@ -33,7 +42,6 @@ async function renderAccount(
|
||||||
|
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
|
const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
|
||||||
const role = event.user?.tags.find(([name]) => name === 'role')?.[1] ?? 'user';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pubkey,
|
id: pubkey,
|
||||||
|
|
@ -74,13 +82,15 @@ async function renderAccount(
|
||||||
username: parsed05?.nickname || npub.substring(0, 8),
|
username: parsed05?.nickname || npub.substring(0, 8),
|
||||||
ditto: {
|
ditto: {
|
||||||
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
|
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
|
||||||
is_registered: Boolean(event.user),
|
|
||||||
},
|
},
|
||||||
pleroma: {
|
pleroma: {
|
||||||
is_admin: role === 'admin',
|
deactivated: names.has('disabled') || names.has('suspended'),
|
||||||
is_moderator: ['admin', 'moderator'].includes(role),
|
is_admin: names.has('admin'),
|
||||||
|
is_moderator: names.has('admin') || names.has('moderator'),
|
||||||
|
is_suggested: names.has('suggested'),
|
||||||
is_local: parsed05?.domain === Conf.url.host,
|
is_local: parsed05?.domain === Conf.url.host,
|
||||||
settings_store: undefined as unknown,
|
settings_store: undefined as unknown,
|
||||||
|
tags: [...getTagSet(event.user?.tags ?? [], 't')],
|
||||||
},
|
},
|
||||||
nostr: {
|
nostr: {
|
||||||
pubkey,
|
pubkey,
|
||||||
|
|
@ -90,7 +100,7 @@ async function renderAccount(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) {
|
function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): Promise<MastodonAccount> {
|
||||||
const event: UnsignedEvent = {
|
const event: UnsignedEvent = {
|
||||||
kind: 0,
|
kind: 0,
|
||||||
pubkey,
|
pubkey,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
|
|
||||||
/** Expects a kind 0 fully hydrated */
|
/** Expects a kind 0 fully hydrated */
|
||||||
async function renderAdminAccount(event: DittoEvent) {
|
async function renderAdminAccount(event: DittoEvent) {
|
||||||
const account = await renderAccount(event);
|
const account = await renderAccount(event);
|
||||||
|
const names = getTagSet(event.user?.tags ?? [], 'n');
|
||||||
|
|
||||||
|
let role = 'user';
|
||||||
|
|
||||||
|
if (names.has('admin')) {
|
||||||
|
role = 'admin';
|
||||||
|
}
|
||||||
|
if (names.has('moderator')) {
|
||||||
|
role = 'moderator';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
|
|
@ -15,12 +26,13 @@ async function renderAdminAccount(event: DittoEvent) {
|
||||||
ips: [],
|
ips: [],
|
||||||
locale: '',
|
locale: '',
|
||||||
invite_request: null,
|
invite_request: null,
|
||||||
role: event.tags.find(([name]) => name === 'role')?.[1],
|
role,
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
approved: true,
|
approved: true,
|
||||||
disabled: false,
|
disabled: names.has('disabled'),
|
||||||
silenced: false,
|
silenced: names.has('silenced'),
|
||||||
suspended: false,
|
suspended: names.has('suspended'),
|
||||||
|
sensitized: names.has('sensitized'),
|
||||||
account,
|
account,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { nostrDate } from '@/utils.ts';
|
import { nostrDate } from '@/utils.ts';
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { NostrEvent } from '@nostrify/nostrify';
|
|
||||||
|
|
||||||
interface RenderNotificationOpts {
|
interface RenderNotificationOpts {
|
||||||
viewerPubkey: string;
|
viewerPubkey: string;
|
||||||
|
|
@ -26,6 +28,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) {
|
||||||
if (event.kind === 7) {
|
if (event.kind === 7) {
|
||||||
return renderReaction(event, opts);
|
return renderReaction(event, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.kind === 30360 && event.pubkey === Conf.pubkey) {
|
||||||
|
return renderNameGrant(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
|
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
|
||||||
|
|
@ -45,7 +51,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
|
||||||
if (event.repost?.kind !== 1) return;
|
if (event.repost?.kind !== 1) return;
|
||||||
const status = await renderStatus(event.repost, opts);
|
const status = await renderStatus(event.repost, opts);
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: notificationId(event),
|
id: notificationId(event),
|
||||||
|
|
@ -60,7 +66,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts)
|
||||||
if (event.reacted?.kind !== 1) return;
|
if (event.reacted?.kind !== 1) return;
|
||||||
const status = await renderStatus(event.reacted, opts);
|
const status = await renderStatus(event.reacted, opts);
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: notificationId(event),
|
id: notificationId(event),
|
||||||
|
|
@ -75,7 +81,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
|
||||||
if (event.reacted?.kind !== 1) return;
|
if (event.reacted?.kind !== 1) return;
|
||||||
const status = await renderStatus(event.reacted, opts);
|
const status = await renderStatus(event.reacted, opts);
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: notificationId(event),
|
id: notificationId(event),
|
||||||
|
|
@ -87,6 +93,21 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderNameGrant(event: DittoEvent) {
|
||||||
|
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
|
if (!d) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: notificationId(event),
|
||||||
|
type: 'ditto:name_grant',
|
||||||
|
name: d,
|
||||||
|
created_at: nostrDate(event.created_at).toISOString(),
|
||||||
|
account,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** This helps notifications be sorted in the correct order. */
|
/** This helps notifications be sorted in the correct order. */
|
||||||
function notificationId({ id, created_at }: NostrEvent): string {
|
function notificationId({ id, created_at }: NostrEvent): string {
|
||||||
return `${created_at}-${id}`;
|
return `${created_at}-${id}`;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { nostrDate } from '@/utils.ts';
|
import { nostrDate } from '@/utils.ts';
|
||||||
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
|
|
||||||
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
|
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
|
||||||
async function renderReport(event: DittoEvent) {
|
async function renderReport(event: DittoEvent) {
|
||||||
|
|
@ -30,43 +31,42 @@ async function renderReport(event: DittoEvent) {
|
||||||
|
|
||||||
interface RenderAdminReportOpts {
|
interface RenderAdminReportOpts {
|
||||||
viewerPubkey?: string;
|
viewerPubkey?: string;
|
||||||
actionTaken?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin-level information about a filed report.
|
/** Admin-level information about a filed report.
|
||||||
* Expects an event of kind 1984 fully hydrated.
|
* Expects an event of kind 1984 fully hydrated.
|
||||||
* https://docs.joinmastodon.org/entities/Admin_Report */
|
* https://docs.joinmastodon.org/entities/Admin_Report */
|
||||||
async function renderAdminReport(reportEvent: DittoEvent, opts: RenderAdminReportOpts) {
|
async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) {
|
||||||
const { viewerPubkey, actionTaken = false } = opts;
|
const { viewerPubkey } = opts;
|
||||||
|
|
||||||
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
|
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
|
||||||
const category = reportEvent.tags.find(([name]) => name === 'p')?.[2];
|
const category = event.tags.find(([name]) => name === 'p')?.[2];
|
||||||
|
|
||||||
const statuses = [];
|
const statuses = [];
|
||||||
if (reportEvent.reported_notes) {
|
if (event.reported_notes) {
|
||||||
for (const status of reportEvent.reported_notes) {
|
for (const status of event.reported_notes) {
|
||||||
statuses.push(await renderStatus(status, { viewerPubkey }));
|
statuses.push(await renderStatus(status, { viewerPubkey }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1];
|
const reportedPubkey = event.tags.find(([name]) => name === 'p')?.[1];
|
||||||
if (!reportedPubkey) {
|
if (!reportedPubkey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const names = getTagSet(event.info?.tags ?? [], 'n');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: reportEvent.id,
|
id: event.id,
|
||||||
action_taken: actionTaken,
|
action_taken: names.has('closed'),
|
||||||
action_taken_at: null,
|
action_taken_at: null,
|
||||||
category,
|
category,
|
||||||
comment: reportEvent.content,
|
comment: event.content,
|
||||||
forwarded: false,
|
forwarded: false,
|
||||||
created_at: nostrDate(reportEvent.created_at).toISOString(),
|
created_at: nostrDate(event.created_at).toISOString(),
|
||||||
account: reportEvent.author
|
account: event.author ? await renderAdminAccount(event.author) : await renderAdminAccountFromPubkey(event.pubkey),
|
||||||
? await renderAdminAccount(reportEvent.author)
|
target_account: event.reported_profile
|
||||||
: await renderAdminAccountFromPubkey(reportEvent.pubkey),
|
? await renderAdminAccount(event.reported_profile)
|
||||||
target_account: reportEvent.reported_profile
|
|
||||||
? await renderAdminAccount(reportEvent.reported_profile)
|
|
||||||
: await renderAdminAccountFromPubkey(reportedPubkey),
|
: await renderAdminAccountFromPubkey(reportedPubkey),
|
||||||
assigned_account: null,
|
assigned_account: null,
|
||||||
action_taken_by_account: null,
|
action_taken_by_account: null,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { MastodonMention } from '@/entities/MastodonMention.ts';
|
||||||
|
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
|
||||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { nostrDate } from '@/utils.ts';
|
import { nostrDate } from '@/utils.ts';
|
||||||
|
|
@ -17,7 +19,7 @@ interface RenderStatusOpts {
|
||||||
depth?: number;
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<any> {
|
async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<MastodonStatus | undefined> {
|
||||||
const { viewerPubkey, depth = 1 } = opts;
|
const { viewerPubkey, depth = 1 } = opts;
|
||||||
|
|
||||||
if (depth > 2 || depth < 0) return;
|
if (depth > 2 || depth < 0) return;
|
||||||
|
|
@ -100,10 +102,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
|
||||||
sensitive: !!cw,
|
sensitive: !!cw,
|
||||||
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
|
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null,
|
language: event.tags.find((tag) => tag[0] === 'l' && tag[2] === 'ISO-639-1')?.[1] || null,
|
||||||
replies_count: event.event_stats?.replies_count ?? 0,
|
replies_count: event.event_stats?.replies_count ?? 0,
|
||||||
reblogs_count: event.event_stats?.reposts_count ?? 0,
|
reblogs_count: event.event_stats?.reposts_count ?? 0,
|
||||||
favourites_count: event.event_stats?.reactions['+'] ?? 0,
|
favourites_count: event.event_stats?.reactions['+'] ?? 0,
|
||||||
|
zaps_amount: event.event_stats?.zaps_amount ?? 0,
|
||||||
favourited: reactionEvent?.content === '+',
|
favourited: reactionEvent?.content === '+',
|
||||||
reblogged: Boolean(repostEvent),
|
reblogged: Boolean(repostEvent),
|
||||||
muted: false,
|
muted: false,
|
||||||
|
|
@ -118,8 +121,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
|
||||||
poll: null,
|
poll: null,
|
||||||
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
|
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
|
||||||
quote_id: event.quote?.id ?? null,
|
quote_id: event.quote?.id ?? null,
|
||||||
uri: Conf.external(note),
|
uri: Conf.local(`/${note}`),
|
||||||
url: Conf.external(note),
|
url: Conf.local(`/${note}`),
|
||||||
zapped: Boolean(zapEvent),
|
zapped: Boolean(zapEvent),
|
||||||
pleroma: {
|
pleroma: {
|
||||||
emoji_reactions: reactions,
|
emoji_reactions: reactions,
|
||||||
|
|
@ -129,12 +132,14 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderReblog(event: DittoEvent, opts: RenderStatusOpts) {
|
async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise<MastodonStatus | undefined> {
|
||||||
const { viewerPubkey } = opts;
|
const { viewerPubkey } = opts;
|
||||||
if (!event.repost) return;
|
if (!event.repost) return;
|
||||||
|
|
||||||
const status = await renderStatus(event, {}); // omit viewerPubkey intentionally
|
const status = await renderStatus(event, {}); // omit viewerPubkey intentionally
|
||||||
const reblog = await renderStatus(event.repost, { viewerPubkey });
|
if (!status) return;
|
||||||
|
|
||||||
|
const reblog = await renderStatus(event.repost, { viewerPubkey }) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...status,
|
...status,
|
||||||
|
|
@ -144,7 +149,7 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toMention(pubkey: string, event?: NostrEvent) {
|
async function toMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> {
|
||||||
const account = event ? await renderAccount(event) : undefined;
|
const account = event ? await renderAccount(event) : undefined;
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
|
|
@ -165,9 +170,7 @@ async function toMention(pubkey: string, event?: NostrEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mention = Awaited<ReturnType<typeof toMention>>;
|
function buildInlineRecipients(mentions: MastodonMention[]): string {
|
||||||
|
|
||||||
function buildInlineRecipients(mentions: Mention[]): string {
|
|
||||||
if (!mentions.length) return '';
|
if (!mentions.length) return '';
|
||||||
|
|
||||||
const elements = mentions.reduce<string[]>((acc, { url, username }) => {
|
const elements = mentions.reduce<string[]>((acc, { url, username }) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue