Merge remote-tracking branch 'origin/main' into feat-persist-user-preference

This commit is contained in:
Alex Gleason 2024-05-20 14:02:38 -05:00
commit 590da75cf2
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
176 changed files with 3607 additions and 2299 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.env
*.cpuprofile
*.swp
deno-test.xml
/data

4
.gitignore vendored
View File

@ -1,2 +1,4 @@
.env .env
*.cpuprofile *.cpuprofile
*.swp
deno-test.xml

View File

@ -1,4 +1,4 @@
image: denoland/deno:1.41.3 image: denoland/deno:1.43.3
default: default:
interruptible: true interruptible: true
@ -22,4 +22,10 @@ test:
stage: test stage: test
script: deno task test script: deno task test
variables: variables:
DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz
artifacts:
when: always
paths:
- deno-test.xml
reports:
junit: deno-test.xml

4
.hooks/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/hook.sh"
deno run -A npm:lint-staged

3
.lintstagedrc Normal file
View File

@ -0,0 +1,3 @@
{
"*.{ts,tsx,md}": "deno fmt"
}

View File

@ -1 +1 @@
deno 1.41.3 deno 1.43.3

8
Dockerfile Normal file
View File

@ -0,0 +1,8 @@
FROM denoland/deno:1.43.3
EXPOSE 4036
WORKDIR /app
RUN mkdir -p data && chown -R deno data
USER deno
COPY . .
RUN deno cache src/server.ts
CMD deno task start

View File

@ -3,6 +3,8 @@
Ditto is a Nostr server for building resilient communities online. Ditto is a Nostr server for building resilient communities online.
With Ditto, you can create your own social network that is decentralized, customizable, and free from ads and tracking. With Ditto, you can create your own social network that is decentralized, customizable, and free from ads and tracking.
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. ⚠️ This software is a work in progress.
@ -14,10 +16,11 @@ With Ditto, you can create your own social network that is decentralized, custom
- [x] Like and comment on posts - [x] Like and comment on posts
- [x] Share posts - [x] Share posts
- [x] Reposts - [x] Reposts
- [ ] Notifications - [x] Notifications
- [x] Profiles - [x] Profiles
- [ ] Search - [ ] Search
- [ ] Moderation - [x] Moderation
- [ ] Zaps
- [x] Customizable - [x] Customizable
- [x] Open source - [x] Open source
- [x] Self-hosted - [x] Self-hosted
@ -38,7 +41,7 @@ With Ditto, you can create your own social network that is decentralized, custom
## License ## License
© Alex Gleason & other Ditto contributors © Alex Gleason & other Ditto contributors
Ditto is free software: you can redistribute it and/or modify Ditto is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

View File

@ -4,27 +4,57 @@
"tasks": { "tasks": {
"start": "deno run -A src/server.ts", "start": "deno run -A src/server.ts",
"dev": "deno run -A --watch src/server.ts", "dev": "deno run -A --watch src/server.ts",
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts",
"debug": "deno run -A --inspect src/server.ts", "debug": "deno run -A --inspect src/server.ts",
"test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A", "test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A --junit-path=./deno-test.xml",
"check": "deno check src/server.ts", "check": "deno check src/server.ts",
"relays:sync": "deno run -A scripts/relays.ts sync",
"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",
"stats:recompute": "deno run -A scripts/stats-recompute.ts"
}, },
"unstable": ["ffi", "kv"], "unstable": ["ffi", "kv", "worker-options"],
"exclude": ["./public"], "exclude": ["./public"],
"imports": { "imports": {
"@/": "./src/", "@/": "./src/",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.15.0", "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
"@db/sqlite": "jsr:@db/sqlite@^0.11.1",
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.22.0",
"@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/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0",
"@std/assert": "jsr:@std/assert@^0.225.1",
"@std/cli": "jsr:@std/cli@^0.223.0", "@std/cli": "jsr:@std/cli@^0.223.0",
"@std/crypto": "jsr:@std/crypto@^0.224.0",
"@std/dotenv": "jsr:@std/dotenv@^0.224.0",
"@std/encoding": "jsr:@std/encoding@^0.224.0",
"@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/streams": "jsr:@std/streams@^0.223.0", "@std/streams": "jsr:@std/streams@^0.223.0",
"comlink": "npm:comlink@^4.4.1",
"deno-safe-fetch": "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts",
"fast-stable-stringify": "npm:fast-stable-stringify@^1.0.0",
"formdata-helper": "npm:formdata-helper@^0.3.0",
"hono": "https://deno.land/x/hono@v3.10.1/mod.ts", "hono": "https://deno.land/x/hono@v3.10.1/mod.ts",
"hono/middleware": "https://deno.land/x/hono@v3.10.1/middleware.ts", "hono/middleware": "https://deno.land/x/hono@v3.10.1/middleware.ts",
"kysely": "npm:kysely@^0.26.3", "iso-639-1": "npm:iso-639-1@2.1.15",
"kysely": "npm:kysely@^0.27.3",
"kysely_deno_postgres": "https://deno.land/x/kysely_deno_postgres@v0.4.0/mod.ts", "kysely_deno_postgres": "https://deno.land/x/kysely_deno_postgres@v0.4.0/mod.ts",
"zod": "npm:zod@^3.23.4", "linkify-plugin-hashtag": "npm:linkify-plugin-hashtag@^4.1.1",
"linkify-string": "npm:linkify-string@^4.1.1",
"linkifyjs": "npm:linkifyjs@^4.1.1",
"lru-cache": "npm:lru-cache@^10.2.2",
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
"nostr-tools": "npm:nostr-tools@^2.5.1",
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
"tldts": "npm:tldts@^6.0.14",
"tseep": "npm:tseep@^1.2.1",
"type-fest": "npm:type-fest@^4.3.0",
"unfurl.js": "npm:unfurl.js@^6.4.0",
"zod": "npm:zod@^3.23.5",
"~/fixtures/": "./fixtures/" "~/fixtures/": "./fixtures/"
}, },
"lint": { "lint": {

View File

@ -9,9 +9,7 @@ The Ditto server publishes kind `30361` events to represent users. These events
User events have the following tags: User events have the following tags:
- `d` - pubkey of the user. - `d` - pubkey of the user.
- `name` - NIP-05 username granted to the user, without the domain.
- `role` - one of `admin` or `user`. - `role` - one of `admin` or `user`.
- `origin` - the origin of the user's NIP-05, at the time the event was published.
Example: Example:
@ -25,7 +23,6 @@ Example:
"tags": [ "tags": [
["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"], ["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],
["role", "user"], ["role", "user"],
["origin", "https://ditto.ngrok.app"],
["alt", "User's account was updated by the admins of ditto.ngrok.app"] ["alt", "User's account was updated by the admins of ditto.ngrok.app"]
], ],
"sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507" "sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507"
@ -40,4 +37,4 @@ The sections below describe the `content` field. Some are encrypted and some are
### `pub.ditto.pleroma.config` ### `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. NIP-04 encrypted JSON array of Pleroma ConfigDB objects. Pleroma admin API endpoints set this config, and Ditto reads from it.

View File

@ -0,0 +1,9 @@
{
"id": "2238893aee54bbe9188498a5aa124d62870d5757894bf52cdb362d1a0874ed18",
"pubkey": "c9f5508526e213c3bc5468161f1b738a86063a2ece540730f9412e7becd5f0b2",
"created_at": 1715517440,
"kind": 0,
"tags": [],
"content": "{\"name\":\"dictator\",\"about\":\"\",\"nip05\":\"\"}",
"sig": "a630ba158833eea10289fe077087ccad22c71ddfbe475153958cfc158ae94fb0a5f7b7626e62da6a3ef8bfbe67321e8f993517ed7f1578a45aff11bc2bec484c"
}

View File

@ -0,0 +1,9 @@
{
"id": "da4e1e727c6456cee2b0341a1d7a2356e4263523374a2570a7dd318ab5d73f93",
"pubkey": "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700",
"created_at": 1715517565,
"kind": 0,
"tags": [],
"content": "{\"name\":\"george orwell\",\"about\":\"\",\"nip05\":\"\"}",
"sig": "cd375e2065cf452d3bfefa9951b04ab63018ab7c253803256cca1d89d03b38e454c71ed36fdd3c28a8ff2723cc19b21371ce0f9bbd39a92b1d1aa946137237bd"
}

View File

@ -0,0 +1,9 @@
{
"id": "44f19148f5af60b0f43ed8c737fbda31b165e05bb55562003c45d9a9f02e8228",
"pubkey": "e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700",
"created_at": 1715636249,
"kind": 1,
"tags": [],
"content": "I like free speech",
"sig": "6b50db9c1c02bd8b0e64512e71d53a0058569f44e8dcff65ad17fce544d6ae79f8f79fa0f9a615446fa8cbc2375709bf835751843b0cd10e62ae5d505fe106d4"
}

View File

@ -0,0 +1,24 @@
{
"id": "129b2749330a7f1189d3e74c6764a955851f1e4017a818dfd51ab8e24192b0f3",
"pubkey": "c9f5508526e213c3bc5468161f1b738a86063a2ece540730f9412e7becd5f0b2",
"created_at": 1715636348,
"kind": 1984,
"tags": [
[
"p",
"e4d96e951739787e62ada74ee06a9a185af22791a899a6166ec23aab58c5d700",
"other"
],
[
"P",
"e724b1c1b90eab9cc0f5976b380b80dda050de1820dc143e62d9e4f27a9a0b2c"
],
[
"e",
"44f19148f5af60b0f43ed8c737fbda31b165e05bb55562003c45d9a9f02e8228",
"other"
]
],
"content": "freedom of speech not freedom of reach",
"sig": "cd05a14749cdf0c7664d056e2c02518740000387732218dacd0c71de5b96c0c3c99a0b927b0cd0778f25a211525fa03b4ed4f4f537bb1221c73467780d4ee1bc"
}

View File

@ -0,0 +1,34 @@
{
"status": "success",
"message": "Upload successful.",
"data": [
{
"input_name": "APIv2",
"name": "e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"sha256": "0a71f1c9dd982079bc52e96403368209cbf9507c5f6956134686f56e684b6377",
"original_sha256": "e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3",
"type": "picture",
"mime": "image/gif",
"size": 1796276,
"blurhash": "LGH-S^Vwm]x]04kX-qR-R]SL5FxZ",
"dimensions": {
"width": 360,
"height": 216
},
"dimensionsString": "360x216",
"url": "https://image.nostr.build/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"thumbnail": "https://image.nostr.build/thumb/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"responsive": {
"240p": "https://image.nostr.build/resp/240p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"360p": "https://image.nostr.build/resp/360p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"480p": "https://image.nostr.build/resp/480p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"720p": "https://image.nostr.build/resp/720p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif",
"1080p": "https://image.nostr.build/resp/1080p/e5f6e0e380536780efa774e8d3c8a5a040e3f9f99dbb48910b261c32872ee3a3.gif"
},
"metadata": {
"date:create": "2024-05-18T02:11:39+00:00",
"date:modify": "2024-05-18T02:11:39+00:00"
}
}
]
}

View File

@ -0,0 +1,29 @@
{
"status": "success",
"message": "Upload successful.",
"data": [
{
"id": 0,
"input_name": "APIv2",
"name": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"url": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"thumbnail": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"responsive": {
"240p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"360p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"480p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"720p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3",
"1080p": "https://media.nostr.build/av/f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725.mp3"
},
"blurhash": "",
"sha256": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725",
"original_sha256": "f94665e6877741feb3fa3031342f95ae2ee00caae1cc651ce31ed6d524e05725",
"type": "video",
"mime": "audio/mpeg",
"size": 1519616,
"metadata": [],
"dimensions": [],
"dimensionsString": "0x0"
}
]
}

View File

@ -3,7 +3,7 @@
# 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.
upstream ditto { upstream ditto {
server 127.0.0.1:8000; server 127.0.0.1:4036;
} }
upstream ipfs_gateway { upstream ipfs_gateway {

View File

@ -1,15 +1,16 @@
import { JsonParseStream } from '@std/json/json-parse-stream'; import { JsonParseStream } from '@std/json/json-parse-stream';
import { TextLineStream } from '@std/streams/text-line-stream'; import { TextLineStream } from '@std/streams/text-line-stream';
import { db } from '@/db.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { EventsDB } from '@/storages/events-db.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { type EventStub } from '@/utils/api.ts'; import { type EventStub } from '@/utils/api.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
const signer = new AdminSigner(); const signer = new AdminSigner();
const eventsDB = new EventsDB(db); const kysely = await DittoDB.getInstance();
const eventsDB = new EventsDB(kysely);
const readable = Deno.stdin.readable const readable = Deno.stdin.readable
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())

View File

@ -1,12 +1,13 @@
import { NSchema } from '@nostrify/nostrify'; import { NSchema } from '@nostrify/nostrify';
import { db } from '@/db.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { EventsDB } from '@/storages/events-db.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
const eventsDB = new EventsDB(db); const kysely = await DittoDB.getInstance();
const eventsDB = new EventsDB(kysely);
const [pubkey, role] = Deno.args; const [pubkey, role] = Deno.args;

View File

@ -1,23 +0,0 @@
import { addRelays } from '@/db/relays.ts';
import { filteredArray } from '@/schema.ts';
import { relaySchema } from '@/utils.ts';
switch (Deno.args[0]) {
case 'sync':
await sync(Deno.args.slice(1));
break;
default:
console.log('Usage: deno run -A scripts/relays.ts sync <url>');
}
async function sync([url]: string[]) {
if (!url) {
console.error('Error: please provide a URL');
Deno.exit(1);
}
const response = await fetch(url);
const data = await response.json();
const values = filteredArray(relaySchema).parse(data) as `wss://${string}`[];
await addRelays(values, { active: true });
console.log(`Done: added ${values.length} relays.`);
}

View File

@ -0,0 +1,18 @@
import { nip19 } from 'nostr-tools';
import { refreshAuthorStats } from '@/stats.ts';
let pubkey: string;
try {
const result = nip19.decode(Deno.args[0]);
if (result.type === 'npub') {
pubkey = result.data;
} else {
throw new Error('Invalid npub');
}
} catch {
console.error('Invalid npub');
Deno.exit(1);
}
await refreshAuthorStats(pubkey);

24
src/RelayError.ts Normal file
View File

@ -0,0 +1,24 @@
import { NostrRelayOK } from '@nostrify/nostrify';
export type RelayErrorPrefix = 'duplicate' | 'pow' | 'blocked' | 'rate-limited' | 'invalid' | 'error';
/** NIP-01 command line result. */
export class RelayError extends Error {
constructor(prefix: RelayErrorPrefix, message: string) {
super(`${prefix}: ${message}`);
}
/** Construct a RelayError from the reason message. */
static fromReason(reason: string): RelayError {
const [prefix, ...rest] = reason.split(': ');
return new RelayError(prefix as RelayErrorPrefix, rest.join(': '));
}
/** Throw a new RelayError if the OK message is false. */
static assert(msg: NostrRelayOK): void {
const [, , ok, reason] = msg;
if (!ok) {
throw RelayError.fromReason(reason);
}
}
}

View File

@ -1,12 +1,10 @@
import { NostrEvent, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { type Context, Env as HonoEnv, type Handler, Hono, Input as HonoInput, type MiddlewareHandler } from 'hono'; import { type Context, Env as HonoEnv, type Handler, Hono, Input as HonoInput, type MiddlewareHandler } from 'hono';
import { cors, logger, serveStatic } from 'hono/middleware'; import { cors, logger, serveStatic } from 'hono/middleware';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import '@/cron.ts'; import { startFirehose } from '@/firehose.ts';
import { type User } from '@/db/users.ts';
import { Debug, sentryMiddleware } from '@/deps.ts';
import '@/firehose.ts';
import { Time } from '@/utils.ts'; import { Time } from '@/utils.ts';
import { actorController } from '@/controllers/activitypub/actor.ts'; import { actorController } from '@/controllers/activitypub/actor.ts';
@ -15,25 +13,28 @@ import {
accountLookupController, accountLookupController,
accountSearchController, accountSearchController,
accountStatusesController, accountStatusesController,
blockController,
createAccountController, createAccountController,
favouritesController, favouritesController,
followController, followController,
followersController, followersController,
followingController, followingController,
muteController,
relationshipsController, relationshipsController,
unblockController,
unfollowController, unfollowController,
unmuteController,
updateCredentialsController, updateCredentialsController,
verifyCredentialsController, verifyCredentialsController,
} from '@/controllers/api/accounts.ts'; } from '@/controllers/api/accounts.ts';
import { adminAccountsController } from '@/controllers/api/admin.ts'; import { adminAccountAction, adminAccountsController } 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 { 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 { instanceController } from '@/controllers/api/instance.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 { notificationsController } from '@/controllers/api/notifications.ts'; import { notificationsController } from '@/controllers/api/notifications.ts';
import { createTokenController, oauthAuthorizeController, oauthController } from '@/controllers/api/oauth.ts'; import { createTokenController, oauthAuthorizeController, oauthController } from '@/controllers/api/oauth.ts';
import { import {
@ -44,6 +45,12 @@ import {
} from '@/controllers/api/pleroma.ts'; } from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.ts'; import { preferencesController } from '@/controllers/api/preferences.ts';
import { relayController } from '@/controllers/nostr/relay.ts'; import { relayController } from '@/controllers/nostr/relay.ts';
import {
adminReportController,
adminReportResolveController,
adminReportsController,
reportController,
} from '@/controllers/api/reports.ts';
import { searchController } from '@/controllers/api/search.ts'; import { searchController } from '@/controllers/api/search.ts';
import { import {
bookmarkController, bookmarkController,
@ -62,6 +69,7 @@ import {
zapController, zapController,
} 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 { import {
hashtagTimelineController, hashtagTimelineController,
homeTimelineController, homeTimelineController,
@ -73,25 +81,26 @@ import { hostMetaController } from '@/controllers/well-known/host-meta.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 { webfingerController } from '@/controllers/well-known/webfinger.ts'; import { webfingerController } from '@/controllers/well-known/webfinger.ts';
import { auth19, requirePubkey } from '@/middleware/auth19.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
import { auth98, requireProof, requireRole } from '@/middleware/auth98.ts'; import { cacheMiddleware } from '@/middleware/cacheMiddleware.ts';
import { cache } from '@/middleware/cache.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
import { csp } from '@/middleware/csp.ts'; import { requireSigner } from '@/middleware/requireSigner.ts';
import { adminRelaysController } from '@/controllers/api/ditto.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
import { storeMiddleware } from '@/middleware/store.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
import { blockController } from '@/controllers/api/accounts.ts';
import { unblockController } from '@/controllers/api/accounts.ts';
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
interface AppEnv extends HonoEnv { interface AppEnv extends HonoEnv {
Variables: { Variables: {
/** Hex pubkey for the current user. If provided, the user is considered "logged in." */ /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */
pubkey?: string; signer?: NostrSigner;
/** Hex secret key for the current user. Optional, but easiest way to use legacy Mastodon apps. */ /** Uploader for the user to upload files. */
seckey?: Uint8Array; uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */ /** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent; proof?: NostrEvent;
/** User associated with the pubkey, if any. */
user?: User;
/** Store */ /** Store */
store?: NStore; store: NStore;
}; };
} }
@ -101,13 +110,12 @@ type AppController = Handler<AppEnv, any, HonoInput, Response | Promise<Response
const app = new Hono<AppEnv>(); const app = new Hono<AppEnv>();
if (Conf.sentryDsn) {
// @ts-ignore Mismatched hono types.
app.use('*', sentryMiddleware({ dsn: Conf.sentryDsn }));
}
const debug = Debug('ditto:http'); const debug = Debug('ditto:http');
if (Conf.firehoseEnabled) {
startFirehose();
}
app.use('/api/*', logger(debug)); app.use('/api/*', logger(debug));
app.use('/relay/*', logger(debug)); app.use('/relay/*', logger(debug));
app.use('/.well-known/*', logger(debug)); app.use('/.well-known/*', logger(debug));
@ -119,7 +127,15 @@ 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('*', csp(), cors({ origin: '*', exposeHeaders: ['link'] }), auth19, auth98()); app.use(
'*',
cspMiddleware(),
cors({ origin: '*', exposeHeaders: ['link'] }),
signerMiddleware,
uploaderMiddleware,
auth98Middleware(),
storeMiddleware,
);
app.get('/.well-known/webfinger', webfingerController); app.get('/.well-known/webfinger', webfingerController);
app.get('/.well-known/host-meta', hostMetaController); app.get('/.well-known/host-meta', hostMetaController);
@ -130,7 +146,7 @@ app.get('/users/:username', actorController);
app.get('/nodeinfo/:version', nodeInfoSchemaController); app.get('/nodeinfo/:version', nodeInfoSchemaController);
app.get('/api/v1/instance', cache({ cacheName: 'web', expires: Time.minutes(5) }), instanceController); app.get('/api/v1/instance', cacheMiddleware({ cacheName: 'web', expires: Time.minutes(5) }), instanceController);
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);
@ -141,15 +157,17 @@ app.post('/oauth/authorize', oauthAuthorizeController);
app.get('/oauth/authorize', oauthController); app.get('/oauth/authorize', oauthController);
app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController); app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController);
app.get('/api/v1/accounts/verify_credentials', requirePubkey, verifyCredentialsController); app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController);
app.patch('/api/v1/accounts/update_credentials', requirePubkey, updateCredentialsController); app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController);
app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/search', accountSearchController);
app.get('/api/v1/accounts/lookup', accountLookupController); app.get('/api/v1/accounts/lookup', accountLookupController);
app.get('/api/v1/accounts/relationships', requirePubkey, relationshipsController); app.get('/api/v1/accounts/relationships', requireSigner, relationshipsController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requirePubkey, blockController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requireSigner, blockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requirePubkey, unblockController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requireSigner, unblockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', requirePubkey, followController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requireSigner, muteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow', requirePubkey, unfollowController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requireSigner, unmuteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', requireSigner, followController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow', requireSigner, unfollowController);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/followers', followersController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/followers', followersController);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/following', followingController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/following', followingController);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/statuses', accountStatusesController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/statuses', accountStatusesController);
@ -159,22 +177,22 @@ app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/favourited_by', favouritedByControll
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController); app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController); app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController); app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requirePubkey, favouriteController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requireSigner, favouriteController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requirePubkey, bookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requireSigner, bookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requirePubkey, unbookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requirePubkey, pinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requirePubkey, unpinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requirePubkey, zapController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requireSigner, zapController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requirePubkey, reblogStatusController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requirePubkey, unreblogStatusController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController);
app.post('/api/v1/statuses', requirePubkey, createStatusController); app.post('/api/v1/statuses', requireSigner, createStatusController);
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requirePubkey, deleteStatusController); app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController);
app.post('/api/v1/media', mediaController); app.post('/api/v1/media', mediaController);
app.post('/api/v2/media', mediaController); app.post('/api/v2/media', mediaController);
app.get('/api/v1/timelines/home', requirePubkey, storeMiddleware, homeTimelineController); app.get('/api/v1/timelines/home', requireSigner, homeTimelineController);
app.get('/api/v1/timelines/public', storeMiddleware, publicTimelineController); app.get('/api/v1/timelines/public', publicTimelineController);
app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController); app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController);
app.get('/api/v1/preferences', preferencesController); app.get('/api/v1/preferences', preferencesController);
@ -183,13 +201,24 @@ app.get('/api/v2/search', searchController);
app.get('/api/pleroma/frontend_configurations', frontendConfigController); app.get('/api/pleroma/frontend_configurations', frontendConfigController);
app.get('/api/v1/trends/tags', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController); app.get(
app.get('/api/v1/trends', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController); '/api/v1/trends/tags',
cacheMiddleware({ cacheName: 'web', expires: Time.minutes(15) }),
trendingTagsController,
);
app.get('/api/v1/trends', cacheMiddleware({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController);
app.get('/api/v1/notifications', requirePubkey, notificationsController); app.get('/api/v1/suggestions', suggestionsV1Controller);
app.get('/api/v1/favourites', requirePubkey, favouritesController); app.get('/api/v2/suggestions', suggestionsV2Controller);
app.get('/api/v1/bookmarks', requirePubkey, bookmarksController);
app.get('/api/v1/blocks', requirePubkey, blocksController); app.get('/api/v1/notifications', requireSigner, notificationsController);
app.get('/api/v1/favourites', requireSigner, favouritesController);
app.get('/api/v1/bookmarks', requireSigner, bookmarksController);
app.get('/api/v1/blocks', requireSigner, blocksController);
app.get('/api/v1/mutes', requireSigner, mutesController);
app.get('/api/v1/markers', requireProof(), markersController);
app.post('/api/v1/markers', requireProof(), updateMarkersController);
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); 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);
@ -197,14 +226,24 @@ app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigContr
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController); app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController);
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'), adminRelaysController); app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
app.post('/api/v1/reports', requireSigner, reportController);
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController);
app.post(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
requireSigner,
requireRole('admin'),
adminReportResolveController,
);
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction);
// Not (yet) implemented. // Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController); app.get('/api/v1/custom_emojis', emptyArrayController);
app.get('/api/v1/filters', emptyArrayController); app.get('/api/v1/filters', emptyArrayController);
app.get('/api/v1/mutes', emptyArrayController);
app.get('/api/v1/domain_blocks', emptyArrayController); app.get('/api/v1/domain_blocks', emptyArrayController);
app.get('/api/v1/markers', emptyObjectController);
app.get('/api/v1/conversations', emptyArrayController); app.get('/api/v1/conversations', emptyArrayController);
app.get('/api/v1/lists', emptyArrayController); app.get('/api/v1/lists', emptyArrayController);

View File

@ -1,7 +1,8 @@
import url from 'node:url'; import url from 'node:url';
import { z } from 'zod';
import { dotenv, getPublicKey, nip19 } from '@/deps.ts'; import * as dotenv from '@std/dotenv';
import { getPublicKey, nip19 } from 'nostr-tools';
import { z } from 'zod';
/** Load environment config from `.env` */ /** Load environment config from `.env` */
await dotenv.load({ await dotenv.load({
@ -41,7 +42,7 @@ class Conf {
} }
static get port() { static get port() {
return parseInt(Deno.env.get('PORT') || '8000'); return parseInt(Deno.env.get('PORT') || '4036');
} }
static get relay(): `wss://${string}` | `ws://${string}` { static get relay(): `wss://${string}` | `ws://${string}` {
@ -54,7 +55,7 @@ class Conf {
} }
/** 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() {
return Deno.env.get('LOCAL_DOMAIN') || 'http://localhost:8000'; return Deno.env.get('LOCAL_DOMAIN') || `http://localhost:${Conf.port}`;
} }
/** URL to an external Nostr viewer. */ /** URL to an external Nostr viewer. */
static get externalDomain() { static get externalDomain() {
@ -135,10 +136,22 @@ class Conf {
return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001'; return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001';
}, },
}; };
/** nostr.build API endpoint when the `nostrbuild` uploader is used. */
static get nostrbuildEndpoint(): string {
return Deno.env.get('NOSTRBUILD_ENDPOINT') || 'https://nostr.build/api/v2/upload/files';
}
/** Default Blossom servers to use when the `blossom` uploader is set. */
static get blossomServers(): string[] {
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() {
return Deno.env.get('DITTO_UPLOADER'); return Deno.env.get('DITTO_UPLOADER');
} }
/** Location to use for local uploads. */
static get uploadsDir() {
return Deno.env.get('UPLOADS_DIR') || 'data/uploads';
}
/** Media base URL for uploads. */ /** Media base URL for uploads. */
static get mediaDomain() { static get mediaDomain() {
const value = Deno.env.get('MEDIA_DOMAIN'); const value = Deno.env.get('MEDIA_DOMAIN');
@ -203,6 +216,21 @@ class Conf {
} }
}, },
}; };
/** Postgres settings. */
static pg = {
/** Number of connections to use in the pool. */
get poolSize(): number {
return Number(Deno.env.get('PG_POOL_SIZE') ?? 10);
},
};
/** Whether to enable requesting events from known relays. */
static get firehoseEnabled(): boolean {
return optionalBooleanSchema.parse(Deno.env.get('FIREHOSE_ENABLED')) ?? true;
}
/** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
static get policy(): string {
return Deno.env.get('DITTO_POLICY') || new URL('../data/policy.ts', import.meta.url).pathname;
}
} }
const optionalBooleanSchema = z const optionalBooleanSchema = z

View File

@ -9,7 +9,7 @@ const actorController: AppController = async (c) => {
const username = c.req.param('username'); const username = c.req.param('username');
const { signal } = c.req.raw; const { signal } = c.req.raw;
const pointer = await localNip05Lookup(username); const pointer = await localNip05Lookup(c.get('store'), username);
if (!pointer) return notFound(c); if (!pointer) return notFound(c);
const event = await getAuthor(pointer.pubkey, { signal }); const event = await getAuthor(pointer.pubkey, { signal });

View File

@ -1,15 +1,14 @@
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
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 { nip19 } from '@/deps.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts';
import { eventsDB, searchStore } from '@/storages.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts';
import { uploadFile } from '@/upload.ts'; import { uploadFile } from '@/utils/upload.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { lookupAccount } from '@/utils/lookup.ts'; import { lookupAccount } from '@/utils/lookup.ts';
@ -18,7 +17,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { APISigner } from '@/signers/APISigner.ts'; import { bech32ToPubkey } from '@/utils.ts';
const usernameSchema = z const usernameSchema = z
.string().min(1).max(30) .string().min(1).max(30)
@ -30,7 +29,7 @@ const createAccountSchema = z.object({
}); });
const createAccountController: AppController = async (c) => { const createAccountController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const result = createAccountSchema.safeParse(await c.req.json()); const result = createAccountSchema.safeParse(await c.req.json());
if (!result.success) { if (!result.success) {
@ -46,28 +45,32 @@ const createAccountController: AppController = async (c) => {
}; };
const verifyCredentialsController: AppController = async (c) => { const verifyCredentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const event = await getAuthor(pubkey, { relations: ['author_stats'] }); const eventsDB = await Storages.db();
if (event) {
const account = await renderAccount(event, { withSource: true });
const [userPreferencesEvent] = await eventsDB.query([{ const [author, [settingsStore]] = await Promise.all([
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
eventsDB.query([{
authors: [pubkey], authors: [pubkey],
kinds: [30078], kinds: [30078],
'#d': ['pub.ditto.pleroma_settings_store'], '#d': ['pub.ditto.pleroma_settings_store'],
limit: 1, limit: 1,
}]); }]),
if (userPreferencesEvent) { ]);
const signer = new APISigner(c);
const userPreference = JSON.parse(await signer.nip44.decrypt(pubkey, userPreferencesEvent.content));
(account.pleroma as any).settings_store = userPreference;
}
return c.json(account); const account = author
} else { ? await renderAccount(author, { withSource: true })
return c.json(await accountFromPubkey(pubkey, { withSource: true })); : await accountFromPubkey(pubkey, { withSource: true });
if (settingsStore) {
const data = await signer.nip44!.decrypt(pubkey, settingsStore.content);
account.pleroma.settings_store = JSON.parse(data);
} }
return c.json(account);
}; };
const accountController: AppController = async (c) => { const accountController: AppController = async (c) => {
@ -92,28 +95,44 @@ const accountLookupController: AppController = async (c) => {
if (event) { if (event) {
return c.json(await renderAccount(event)); return c.json(await renderAccount(event));
} }
try {
return c.json({ error: 'Could not find user.' }, 404); const pubkey = bech32ToPubkey(decodeURIComponent(acct)) as string;
return c.json(await accountFromPubkey(pubkey));
} catch (e) {
console.log(e);
return c.json({ error: 'Could not find user.' }, 404);
}
}; };
const accountSearchController: AppController = async (c) => { const accountSearchQuerySchema = z.object({
const q = c.req.query('q'); q: z.string().transform(decodeURIComponent),
resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
});
if (!q) { const accountSearchController: AppController = async (c) => {
return c.json({ error: 'Missing `q` query parameter.' }, 422); const result = accountSearchQuerySchema.safeParse(c.req.query());
const { signal } = c.req.raw;
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422);
} }
const { q, limit } = result.data;
const query = decodeURIComponent(q); const query = decodeURIComponent(q);
const store = await Storages.search();
const [event, events] = await Promise.all([ const [event, events] = await Promise.all([
lookupAccount(query), lookupAccount(query),
searchStore.query([{ kinds: [0], search: query, limit: 20 }], { signal: c.req.raw.signal }), store.query([{ kinds: [0], search: query, limit }], { signal }),
]); ]);
const results = await hydrateEvents({ const results = await hydrateEvents({
events: event ? [event, ...events] : events, events: event ? [event, ...events] : events,
storage: eventsDB, store,
signal: c.req.raw.signal, signal,
}); });
if ((results.length < 1) && query.match(/npub1\w+/)) { if ((results.length < 1) && query.match(/npub1\w+/)) {
@ -132,7 +151,7 @@ const accountSearchController: AppController = async (c) => {
}; };
const relationshipsController: AppController = async (c) => { const relationshipsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const ids = z.array(z.string()).safeParse(c.req.queries('id[]')); const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
if (!ids.success) { if (!ids.success) {
@ -157,8 +176,10 @@ const accountStatusesController: AppController = async (c) => {
const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query()); const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
const store = await Storages.db();
if (pinned) { if (pinned) {
const [pinEvent] = await eventsDB.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal }); const [pinEvent] = await store.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal });
if (pinEvent) { if (pinEvent) {
const pinnedEventIds = getTagSet(pinEvent.tags, 'e'); const pinnedEventIds = getTagSet(pinEvent.tags, 'e');
return renderStatuses(c, [...pinnedEventIds].reverse()); return renderStatuses(c, [...pinnedEventIds].reverse());
@ -179,8 +200,8 @@ const accountStatusesController: AppController = async (c) => {
filter['#t'] = [tagged]; filter['#t'] = [tagged];
} }
const events = await eventsDB.query([filter], { signal }) const events = await store.query([filter], { signal })
.then((events) => hydrateEvents({ events, storage: eventsDB, signal })) .then((events) => hydrateEvents({ events, store, signal }))
.then((events) => { .then((events) => {
if (exclude_replies) { if (exclude_replies) {
return events.filter((event) => !findReplyTag(event.tags)); return events.filter((event) => !findReplyTag(event.tags));
@ -188,7 +209,11 @@ const accountStatusesController: AppController = async (c) => {
return events; return events;
}); });
const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))); const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })),
);
return paginated(c, events, statuses); return paginated(c, events, statuses);
}; };
@ -201,11 +226,12 @@ const updateCredentialsSchema = z.object({
bot: z.boolean().optional(), bot: z.boolean().optional(),
discoverable: z.boolean().optional(), discoverable: z.boolean().optional(),
nip05: z.string().optional(), nip05: z.string().optional(),
pleroma_settings_store: z.object({ soapbox_fe: z.record(z.string(), z.unknown()) }).optional(), pleroma_settings_store: z.unknown().optional(),
}); });
const updateCredentialsController: AppController = async (c) => { const updateCredentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = updateCredentialsSchema.safeParse(body); const result = updateCredentialsSchema.safeParse(body);
@ -214,7 +240,7 @@ const updateCredentialsController: AppController = async (c) => {
} }
const author = await getAuthor(pubkey); const author = await getAuthor(pubkey);
const meta = author ? jsonMetaContentSchema.parse(author.content) : {}; const meta = author ? n.json().pipe(n.metadata()).catch({}).parse(author.content) : {};
const { const {
avatar: avatarFile, avatar: avatarFile,
@ -225,8 +251,8 @@ const updateCredentialsController: AppController = async (c) => {
} = result.data; } = result.data;
const [avatar, header] = await Promise.all([ const [avatar, header] = await Promise.all([
avatarFile ? uploadFile(avatarFile, { pubkey }) : undefined, avatarFile ? uploadFile(c, avatarFile, { pubkey }) : undefined,
headerFile ? uploadFile(headerFile, { pubkey }) : undefined, headerFile ? uploadFile(c, headerFile, { pubkey }) : undefined,
]); ]);
meta.name = display_name ?? meta.name; meta.name = display_name ?? meta.name;
@ -241,55 +267,46 @@ const updateCredentialsController: AppController = async (c) => {
tags: [], tags: [],
}, c); }, c);
const pleroma_frontend = result.data.pleroma_settings_store; const account = await renderAccount(event, { withSource: true });
if (pleroma_frontend) { const settingsStore = result.data.pleroma_settings_store;
const signer = new APISigner(c);
if (settingsStore) {
await createEvent({ await createEvent({
kind: 30078, kind: 30078,
tags: [['d', 'pub.ditto.pleroma_settings_store']], tags: [['d', 'pub.ditto.pleroma_settings_store']],
content: await signer.nip44.encrypt(pubkey, JSON.stringify(pleroma_frontend)), content: await signer.nip44!.encrypt(pubkey, JSON.stringify(settingsStore)),
}, c); }, c);
} }
const account = await renderAccount(event, { withSource: true }); account.pleroma.settings_store = settingsStore;
const [userPreferencesEvent] = await eventsDB.query([{
authors: [pubkey],
kinds: [30078],
'#d': ['pub.ditto.pleroma_settings_store'],
limit: 1,
}]);
if (userPreferencesEvent) {
const signer = new APISigner(c);
const userPreference = JSON.parse(await signer.nip44.decrypt(pubkey, userPreferencesEvent.content));
(account.pleroma as any).settings_store = userPreference;
}
return c.json(account); return c.json(account);
}; };
/** https://docs.joinmastodon.org/methods/accounts/#follow */ /** https://docs.joinmastodon.org/methods/accounts/#follow */
const followController: AppController = async (c) => { const followController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!; const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
{ kinds: [3], authors: [sourcePubkey] }, { kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]), (tags) => addTag(tags, ['p', targetPubkey]),
c, c,
); );
const relationship = await renderRelationship(sourcePubkey, targetPubkey); const relationship = await renderRelationship(sourcePubkey, targetPubkey);
relationship.following = true;
return c.json(relationship); return c.json(relationship);
}; };
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */ /** https://docs.joinmastodon.org/methods/accounts/#unfollow */
const unfollowController: AppController = async (c) => { const unfollowController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!; const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
{ kinds: [3], authors: [sourcePubkey] }, { kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]), (tags) => deleteTag(tags, ['p', targetPubkey]),
c, c,
); );
@ -311,12 +328,22 @@ const followingController: AppController = async (c) => {
}; };
/** https://docs.joinmastodon.org/methods/accounts/#block */ /** https://docs.joinmastodon.org/methods/accounts/#block */
const blockController: AppController = async (c) => { const blockController: AppController = (c) => {
const sourcePubkey = c.get('pubkey')!; return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
};
/** https://docs.joinmastodon.org/methods/accounts/#unblock */
const unblockController: AppController = (c) => {
return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
};
/** https://docs.joinmastodon.org/methods/accounts/#mute */
const muteController: AppController = async (c) => {
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
{ kinds: [10000], authors: [sourcePubkey] }, { kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]), (tags) => addTag(tags, ['p', targetPubkey]),
c, c,
); );
@ -325,13 +352,13 @@ const blockController: AppController = async (c) => {
return c.json(relationship); return c.json(relationship);
}; };
/** https://docs.joinmastodon.org/methods/accounts/#unblock */ /** https://docs.joinmastodon.org/methods/accounts/#unmute */
const unblockController: AppController = async (c) => { const unmuteController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!; const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
{ kinds: [10000], authors: [sourcePubkey] }, { kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]), (tags) => deleteTag(tags, ['p', targetPubkey]),
c, c,
); );
@ -341,11 +368,13 @@ const unblockController: AppController = async (c) => {
}; };
const favouritesController: AppController = async (c) => { const favouritesController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const params = paginationSchema.parse(c.req.query()); const params = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
const events7 = await eventsDB.query( const store = await Storages.db();
const events7 = await store.query(
[{ kinds: [7], authors: [pubkey], ...params }], [{ kinds: [7], authors: [pubkey], ...params }],
{ signal }, { signal },
); );
@ -354,10 +383,14 @@ const favouritesController: AppController = async (c) => {
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1]) .map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
.filter((id): id is string => !!id); .filter((id): id is string => !!id);
const events1 = await eventsDB.query([{ kinds: [1], ids }], { signal }) const events1 = await store.query([{ kinds: [1], ids }], { signal })
.then((events) => hydrateEvents({ events, storage: eventsDB, signal })); .then((events) => hydrateEvents({ events, store, signal }));
const statuses = await Promise.all(events1.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))); const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
events1.map((event) => renderStatus(event, { viewerPubkey })),
);
return paginated(c, events1, statuses); return paginated(c, events1, statuses);
}; };
@ -372,9 +405,11 @@ export {
followController, followController,
followersController, followersController,
followingController, followingController,
muteController,
relationshipsController, relationshipsController,
unblockController, unblockController,
unfollowController, unfollowController,
unmuteController,
updateCredentialsController, updateCredentialsController,
verifyCredentialsController, verifyCredentialsController,
}; };

View File

@ -2,10 +2,12 @@ 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 { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { addTag } from '@/tags.ts';
import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts';
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts'; import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
import { paginated, paginationSchema } from '@/utils/api.ts';
const adminAccountQuerySchema = z.object({ const adminAccountQuerySchema = z.object({
local: booleanParamSchema.optional(), local: booleanParamSchema.optional(),
@ -38,16 +40,17 @@ const adminAccountsController: AppController = async (c) => {
return c.json([]); return c.json([]);
} }
const store = await Storages.db();
const { since, until, limit } = paginationSchema.parse(c.req.query()); const { since, until, limit } = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
const events = await eventsDB.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { 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 pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!);
const authors = await eventsDB.query([{ kinds: [0], authors: pubkeys }], { signal }); const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
for (const event of events) { for (const event of events) {
const d = event.tags.find(([name]) => name === 'd')?.[1]; const d = event.tags.find(([name]) => name === 'd')?.[1];
event.d_author = authors.find((author) => author.pubkey === d); (event as DittoEvent).d_author = authors.find((author) => author.pubkey === d);
} }
const accounts = await Promise.all( const accounts = await Promise.all(
@ -57,4 +60,32 @@ const adminAccountsController: AppController = async (c) => {
return paginated(c, events, accounts); return paginated(c, events, accounts);
}; };
export { adminAccountsController }; const adminAccountActionSchema = z.object({
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']),
});
const adminAccountAction: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = adminAccountActionSchema.safeParse(body);
const authorId = c.req.param('id');
if (!result.success) {
return c.json({ error: 'This action is not allowed' }, 403);
}
const { data } = result;
if (data.type !== 'disable') {
return c.json({ error: 'Record invalid' }, 422);
}
await updateListAdminEvent(
{ kinds: [10000], authors: [Conf.pubkey], limit: 1 },
(tags) => addTag(tags, ['p', authorId]),
c,
);
return c.json({}, 200);
};
export { adminAccountAction, adminAccountsController };

View File

@ -1,24 +1,6 @@
import { type AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { eventsDB } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderAccounts } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/blocks/#get */ /** https://docs.joinmastodon.org/methods/blocks/#get */
const blocksController: AppController = async (c) => { export const blocksController: AppController = (c) => {
const pubkey = c.get('pubkey')!; return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
const { signal } = c.req.raw;
const [event10000] = await eventsDB.query(
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
{ signal },
);
if (event10000) {
const pubkeys = getTagSet(event10000.tags, 'p');
return renderAccounts(c, [...pubkeys].reverse());
} else {
return c.json([]);
}
}; };
export { blocksController };

View File

@ -1,14 +1,15 @@
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts'; import { getTagSet } from '@/tags.ts';
import { renderStatuses } from '@/views.ts'; import { renderStatuses } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/bookmarks/#get */ /** https://docs.joinmastodon.org/methods/bookmarks/#get */
const bookmarksController: AppController = async (c) => { const bookmarksController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const store = await Storages.db();
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw; const { signal } = c.req.raw;
const [event10003] = await eventsDB.query( const [event10003] = await store.query(
[{ kinds: [10003], authors: [pubkey], limit: 1 }], [{ kinds: [10003], authors: [pubkey], limit: 1 }],
{ signal }, { signal },
); );

View File

@ -3,19 +3,22 @@ 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 { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
const markerSchema = z.enum(['read', 'write']);
const relaySchema = z.object({ const relaySchema = z.object({
url: z.string().url(), url: z.string().url(),
read: z.boolean(), marker: markerSchema.optional(),
write: z.boolean(),
}); });
type RelayEntity = z.infer<typeof relaySchema>; type RelayEntity = z.infer<typeof relaySchema>;
export const adminRelaysController: AppController = async (c) => { export const adminRelaysController: AppController = async (c) => {
const [event] = await eventsDB.query([ const store = await Storages.db();
const [event] = await store.query([
{ kinds: [10002], authors: [Conf.pubkey], limit: 1 }, { kinds: [10002], authors: [Conf.pubkey], limit: 1 },
]); ]);
@ -27,16 +30,17 @@ export const adminRelaysController: AppController = async (c) => {
}; };
export const adminSetRelaysController: AppController = async (c) => { export const adminSetRelaysController: AppController = async (c) => {
const store = await Storages.db();
const relays = relaySchema.array().parse(await c.req.json()); const relays = relaySchema.array().parse(await c.req.json());
const event = await new AdminSigner().signEvent({ const event = await new AdminSigner().signEvent({
kind: 10002, kind: 10002,
tags: relays.map(({ url, read, write }) => ['r', url, read && write ? '' : read ? 'read' : 'write']), tags: relays.map(({ url, marker }) => marker ? ['r', url, marker] : ['r', url]),
content: '', content: '',
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}); });
await eventsDB.event(event); await store.event(event);
return c.json(renderRelays(event)); return c.json(renderRelays(event));
}; };
@ -47,8 +51,7 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
if (name === 'r') { if (name === 'r') {
const relay: RelayEntity = { const relay: RelayEntity = {
url, url,
read: !marker || marker === 'read', marker: markerSchema.safeParse(marker).success ? marker as 'read' | 'write' : undefined,
write: !marker || marker === 'write',
}; };
acc.push(relay); acc.push(relay);
} }

View File

@ -1,23 +1,20 @@
import { type AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts';
import { eventsDB } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
const instanceController: AppController = async (c) => { const instanceController: AppController = async (c) => {
const { host, protocol } = Conf.url; const { host, protocol } = Conf.url;
const { signal } = c.req.raw; const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
const [event] = await eventsDB.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal });
const meta = jsonServerMetaSchema.parse(event?.content);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
return c.json({ return c.json({
uri: host, uri: host,
title: meta.name ?? 'Ditto', title: meta.name,
description: meta.about ?? 'Nostr and the Fediverse', description: meta.about,
short_description: meta.tagline ?? meta.about ?? 'Nostr and the Fediverse', short_description: meta.tagline,
registrations: true, registrations: true,
max_toot_chars: Conf.postCharLimit, max_toot_chars: Conf.postCharLimit,
configuration: { configuration: {
@ -43,6 +40,7 @@ const instanceController: AppController = async (c) => {
'mastodon_api_streaming', 'mastodon_api_streaming',
'exposable_reactions', 'exposable_reactions',
'quote_posting', 'quote_posting',
'v2_suggestions',
], ],
}, },
}, },
@ -56,7 +54,7 @@ const instanceController: AppController = async (c) => {
streaming_api: `${wsProtocol}//${host}`, streaming_api: `${wsProtocol}//${host}`,
}, },
version: '0.0.0 (compatible; Ditto 0.0.1)', version: '0.0.0 (compatible; Ditto 0.0.1)',
email: meta.email ?? `postmaster@${host}`, email: meta.email,
nostr: { nostr: {
pubkey: Conf.pubkey, pubkey: Conf.pubkey,
relay: `${wsProtocol}//${host}/relay`, relay: `${wsProtocol}//${host}/relay`,

View File

@ -0,0 +1,64 @@
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { parseBody } from '@/utils/api.ts';
const kv = await Deno.openKv();
type Timeline = 'home' | 'notifications';
interface Marker {
last_read_id: string;
version: number;
updated_at: string;
}
export const markersController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const timelines = c.req.queries('timeline[]') ?? [];
const results = await kv.getMany<Marker[]>(
timelines.map((timeline) => ['markers', pubkey, timeline]),
);
const marker = results.reduce<Record<string, Marker>>((acc, { key, value }) => {
if (value) {
const timeline = key[key.length - 1] as string;
acc[timeline] = value;
}
return acc;
}, {});
return c.json(marker);
};
const markerDataSchema = z.object({
last_read_id: z.string(),
});
export const updateMarkersController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
const timelines = Object.keys(record) as Timeline[];
const markers: Record<string, Marker> = {};
const entries = await kv.getMany<Marker[]>(
timelines.map((timeline) => ['markers', pubkey, timeline]),
);
for (const timeline of timelines) {
const last = entries.find(({ key }) => key[key.length - 1] === timeline);
const marker: Marker = {
last_read_id: record[timeline]!.last_read_id,
version: last?.value ? last.value.version + 1 : 1,
updated_at: new Date().toISOString(),
};
await kv.set(['markers', pubkey, timeline], marker);
markers[timeline] = marker;
}
return c.json(markers);
};

View File

@ -4,7 +4,7 @@ import { AppController } from '@/app.ts';
import { fileSchema } from '@/schema.ts'; import { fileSchema } from '@/schema.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { uploadFile } from '@/upload.ts'; import { uploadFile } from '@/utils/upload.ts';
const mediaBodySchema = z.object({ const mediaBodySchema = z.object({
file: fileSchema, file: fileSchema,
@ -14,7 +14,7 @@ const mediaBodySchema = z.object({
}); });
const mediaController: AppController = async (c) => { const mediaController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw)); const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -24,7 +24,7 @@ const mediaController: AppController = async (c) => {
try { try {
const { file, description } = result.data; const { file, description } = result.data;
const media = await uploadFile(file, { pubkey, description }, signal); const media = await uploadFile(c, file, { pubkey, description }, signal);
return c.json(renderAttachment(media)); return c.json(renderAttachment(media));
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -0,0 +1,25 @@
import { type AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderAccounts } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/mutes/#get */
const mutesController: AppController = async (c) => {
const store = await Storages.db();
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10000] = await store.query(
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
{ signal },
);
if (event10000) {
const pubkeys = getTagSet(event10000.tags, 'p');
return renderAccounts(c, [...pubkeys].reverse());
} else {
return c.json([]);
}
};
export { mutesController };

View File

@ -1,20 +1,40 @@
import { type AppController } from '@/app.ts'; import { NostrFilter } from '@nostrify/nostrify';
import { eventsDB } from '@/storages.ts';
import { AppContext, AppController } from '@/app.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginationSchema } from '@/utils/api.ts'; import { paginated, paginationSchema } from '@/utils/api.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts';
const notificationsController: AppController = async (c) => { const notificationsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const { since, until } = paginationSchema.parse(c.req.query()); const { since, until } = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw;
const events = await eventsDB.query( return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]);
[{ kinds: [1], '#p': [pubkey], since, until }],
{ signal },
);
const statuses = await Promise.all(events.map((event) => renderNotification(event, pubkey)));
return paginated(c, events, statuses);
}; };
async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const events = await store
.query(filters, { signal })
.then((events) => events.filter((event) => event.pubkey !== pubkey))
.then((events) => hydrateEvents({ events, store, signal }));
if (!events.length) {
return c.json([]);
}
const notifications = (await Promise
.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
.filter(Boolean);
if (!notifications.length) {
return c.json([]);
}
return paginated(c, events, notifications);
}
export { notificationsController }; export { notificationsController };

View File

@ -1,9 +1,12 @@
import { encodeBase64 } from '@std/encoding/base64';
import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { lodash, nip19 } from '@/deps.ts';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { lodash } from '@/deps.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
import { getClientConnectUri } from '@/utils/connect.ts';
const passwordGrantSchema = z.object({ const passwordGrantSchema = z.object({
grant_type: z.literal('password'), grant_type: z.literal('password'),
@ -59,25 +62,16 @@ const createTokenController: AppController = async (c) => {
}; };
/** Display the OAuth form. */ /** Display the OAuth form. */
const oauthController: AppController = (c) => { const oauthController: AppController = async (c) => {
const encodedUri = c.req.query('redirect_uri'); const encodedUri = c.req.query('redirect_uri');
if (!encodedUri) { if (!encodedUri) {
return c.text('Missing `redirect_uri` query param.', 422); return c.text('Missing `redirect_uri` query param.', 422);
} }
const redirectUri = maybeDecodeUri(encodedUri); const redirectUri = maybeDecodeUri(encodedUri);
const connectUri = await getClientConnectUri(c.req.raw.signal);
c.res.headers.set( const script = `
'content-security-policy',
"default-src 'self' 'sha256-m2qD6rbE2Ixbo2Bjy2dgQebcotRIAawW7zbmXItIYAM='",
);
return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Log in with Ditto</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script>
window.addEventListener('load', function() { window.addEventListener('load', function() {
if ('nostr' in window) { if ('nostr' in window) {
nostr.getPublicKey().then(function(pubkey) { nostr.getPublicKey().then(function(pubkey) {
@ -86,7 +80,21 @@ const oauthController: AppController = (c) => {
}); });
} }
}); });
</script> `;
const hash = encodeBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(script)));
c.res.headers.set(
'content-security-policy',
`default-src 'self' 'sha256-${hash}'`,
);
return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Log in with Ditto</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script>${script}</script>
</head> </head>
<body> <body>
<form id="oauth_form" action="/oauth/authorize" method="post"> <form id="oauth_form" action="/oauth/authorize" method="post">
@ -95,6 +103,8 @@ const oauthController: AppController = (c) => {
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${lodash.escape(redirectUri)}"> <input type="hidden" name="redirect_uri" id="redirect_uri" value="${lodash.escape(redirectUri)}">
<button type="submit">Authorize</button> <button type="submit">Authorize</button>
</form> </form>
<br>
<a href="${lodash.escape(connectUri)}">Nostr Connect</a>
</body> </body>
</html> </html>
`); `);

View File

@ -1,15 +1,16 @@
import { NSchema as n, NStore } 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 { 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 { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { createAdminEvent } from '@/utils/api.ts'; import { createAdminEvent } from '@/utils/api.ts';
import { jsonSchema } from '@/schema.ts';
const frontendConfigController: AppController = async (c) => { const frontendConfigController: AppController = async (c) => {
const configs = await getConfigs(c.req.raw.signal); const store = await Storages.db();
const configs = await getConfigs(store, c.req.raw.signal);
const frontendConfig = configs.find(({ group, key }) => group === ':pleroma' && key === ':frontend_configurations'); const frontendConfig = configs.find(({ group, key }) => group === ':pleroma' && key === ':frontend_configurations');
if (frontendConfig) { if (frontendConfig) {
@ -25,7 +26,8 @@ const frontendConfigController: AppController = async (c) => {
}; };
const configController: AppController = async (c) => { const configController: AppController = async (c) => {
const configs = await getConfigs(c.req.raw.signal); const store = await Storages.db();
const configs = await getConfigs(store, c.req.raw.signal);
return c.json({ configs, need_reboot: false }); return c.json({ configs, need_reboot: false });
}; };
@ -33,7 +35,8 @@ const configController: AppController = async (c) => {
const updateConfigController: AppController = async (c) => { const updateConfigController: AppController = async (c) => {
const { pubkey } = Conf; const { pubkey } = Conf;
const configs = await getConfigs(c.req.raw.signal); const store = await Storages.db();
const configs = await getConfigs(store, c.req.raw.signal);
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json()); const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
for (const { group, key, value } of newConfigs) { for (const { group, key, value } of newConfigs) {
@ -63,10 +66,10 @@ const pleromaAdminDeleteStatusController: AppController = async (c) => {
return c.json({}); return c.json({});
}; };
async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> { async function getConfigs(store: NStore, signal: AbortSignal): Promise<PleromaConfig[]> {
const { pubkey } = Conf; const { pubkey } = Conf;
const [event] = await eventsDB.query([{ const [event] = await store.query([{
kinds: [30078], kinds: [30078],
authors: [pubkey], authors: [pubkey],
'#d': ['pub.ditto.pleroma.config'], '#d': ['pub.ditto.pleroma.config'],
@ -75,7 +78,7 @@ async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> {
try { try {
const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content); const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content);
return jsonSchema.pipe(configSchema.array()).catch([]).parse(decrypted); return n.json().pipe(configSchema.array()).catch([]).parse(decrypted);
} catch (_e) { } catch (_e) {
return []; return [];
} }

View File

@ -0,0 +1,121 @@
import { NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { createAdminEvent, createEvent, parseBody } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts';
import { renderReport } from '@/views/mastodon/reports.ts';
const reportSchema = z.object({
account_id: n.id(),
status_ids: n.id().array().default([]),
comment: z.string().max(1000).default(''),
category: z.string().default('other'),
// TODO: rules_ids[] is not implemented
});
/** https://docs.joinmastodon.org/methods/reports/#post */
const reportController: AppController = async (c) => {
const store = c.get('store');
const body = await parseBody(c.req.raw);
const result = reportSchema.safeParse(body);
if (!result.success) {
return c.json(result.error, 422);
}
const {
account_id,
status_ids,
comment,
category,
} = result.data;
const tags = [
['p', account_id, category],
['P', Conf.pubkey],
];
for (const status of status_ids) {
tags.push(['e', status, category]);
}
const event = await createEvent({
kind: 1984,
content: comment,
tags,
}, c);
await hydrateEvents({ events: [event], store });
return c.json(await renderReport(event));
};
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => {
const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey();
const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }))
.then((events) =>
Promise.all(
events.map((event) => renderAdminReport(event, { viewerPubkey })),
)
);
return c.json(reports);
};
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
const adminReportController: 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: 'This action is not allowed' }, 403);
}
await hydrateEvents({ events: [event], store, signal });
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey }));
};
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
const adminReportResolveController: 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: 'This action is not allowed' }, 403);
}
await hydrateEvents({ events: [event], store, signal });
await createAdminEvent({
kind: 5,
tags: [['e', event.id]],
content: 'Report closed.',
}, c);
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }));
};
export { adminReportController, adminReportResolveController, adminReportsController, reportController };

View File

@ -1,11 +1,10 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { nip19 } from '@/deps.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts';
import { searchStore } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts'; import { dedupeEvents } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
@ -20,7 +19,7 @@ const searchQuerySchema = z.object({
type: z.enum(['accounts', 'statuses', 'hashtags']).optional(), type: z.enum(['accounts', 'statuses', 'hashtags']).optional(),
resolve: booleanParamSchema.optional().transform(Boolean), resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false), following: z.boolean().default(false),
account_id: nostrIdSchema.optional(), account_id: n.id().optional(),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
}); });
@ -44,6 +43,7 @@ const searchController: AppController = async (c) => {
} }
const results = dedupeEvents(events); const results = dedupeEvents(events);
const viewerPubkey = await c.get('signer')?.getPublicKey();
const [accounts, statuses] = await Promise.all([ const [accounts, statuses] = await Promise.all([
Promise.all( Promise.all(
@ -55,7 +55,7 @@ const searchController: AppController = async (c) => {
Promise.all( Promise.all(
results results
.filter((event) => event.kind === 1) .filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })) .map((event) => renderStatus(event, { viewerPubkey }))
.filter(Boolean), .filter(Boolean),
), ),
]); ]);
@ -78,7 +78,7 @@ const searchController: AppController = async (c) => {
}; };
/** Get events for the search params. */ /** Get events for the search params. */
function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise<NostrEvent[]> { async function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise<NostrEvent[]> {
if (type === 'hashtags') return Promise.resolve([]); if (type === 'hashtags') return Promise.resolve([]);
const filter: NostrFilter = { const filter: NostrFilter = {
@ -91,8 +91,10 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: Abort
filter.authors = [account_id]; filter.authors = [account_id];
} }
return searchStore.query([filter], { signal }) const store = await Storages.search();
.then((events) => hydrateEvents({ events, storage: searchStore, signal }));
return store.query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal }));
} }
/** Get event kinds to search from `type` query param. */ /** Get event kinds to search from `type` query param. */
@ -110,9 +112,10 @@ function typeToKinds(type: SearchQuery['type']): number[] {
/** Resolve a searched value into an event, if applicable. */ /** Resolve a searched value into an event, if applicable. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> { async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
const filters = await getLookupFilters(query, signal); const filters = await getLookupFilters(query, signal);
const store = await Storages.search();
return searchStore.query(filters, { limit: 1, signal }) return store.query(filters, { limit: 1, signal })
.then((events) => hydrateEvents({ events, storage: searchStore, signal })) .then((events) => hydrateEvents({ events, store, signal }))
.then(([event]) => event); .then(([event]) => event);
} }

View File

@ -1,21 +1,22 @@
import { NIP05, NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import ISO6391 from 'iso-639-1';
import { nip19 } from 'nostr-tools';
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 { DittoDB } from '@/db/DittoDB.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { ISO6391, nip19 } from '@/deps.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { addTag, deleteTag } from '@/tags.ts'; import { addTag, deleteTag } from '@/tags.ts';
import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { renderEventAccounts } from '@/views.ts'; import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { getLnurl } from '@/utils/lnurl.ts'; import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { asyncReplaceAll } from '@/utils/text.ts'; import { asyncReplaceAll } from '@/utils/text.ts';
import { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
const createStatusSchema = z.object({ const createStatusSchema = z.object({
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
@ -31,6 +32,7 @@ const createStatusSchema = z.object({
sensitive: z.boolean().nullish(), sensitive: z.boolean().nullish(),
spoiler_text: z.string().nullish(), spoiler_text: z.string().nullish(),
status: z.string().nullish(), status: z.string().nullish(),
to: z.string().array().nullish(),
visibility: z.enum(['public', 'unlisted', 'private', 'direct']).nullish(), visibility: z.enum(['public', 'unlisted', 'private', 'direct']).nullish(),
quote_id: z.string().nullish(), quote_id: z.string().nullish(),
}).refine( }).refine(
@ -47,7 +49,7 @@ const statusController: AppController = async (c) => {
}); });
if (event) { if (event) {
return c.json(await renderStatus(event, { viewerPubkey: c.get('pubkey') })); return c.json(await renderStatus(event, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
} }
return c.json({ error: 'Event not found.' }, 404); return c.json({ error: 'Event not found.' }, 404);
@ -56,6 +58,7 @@ const statusController: AppController = async (c) => {
const createStatusController: AppController = async (c) => { const createStatusController: AppController = async (c) => {
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body); const result = createStatusSchema.safeParse(body);
const kysely = await DittoDB.getInstance();
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 400); return c.json({ error: 'Bad request', schema: result.error }, 400);
@ -89,45 +92,58 @@ const createStatusController: AppController = async (c) => {
tags.push(['subject', data.spoiler_text]); tags.push(['subject', data.spoiler_text]);
} }
if (data.media_ids?.length) { const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
const media = await getUnattachedMediaByIds(data.media_ids)
.then((media) => media.filter(({ pubkey }) => pubkey === c.get('pubkey')))
.then((media) => media.map(({ url, data }) => ['media', url, data]));
tags.push(...media); const imeta: string[][] = media.map(({ data }) => {
} const values: string[] = data.map((tag) => tag.join(' '));
return ['imeta', ...values];
});
tags.push(...imeta);
const pubkeys = new Set<string>();
const content = await asyncReplaceAll(data.status ?? '', /@([\w@+._]+)/g, async (match, username) => { const content = await asyncReplaceAll(data.status ?? '', /@([\w@+._]+)/g, async (match, username) => {
const pubkey = await lookupPubkey(username);
if (!pubkey) return match;
// Content addressing (default)
if (!data.to) {
pubkeys.add(pubkey);
}
try { try {
const result = nip19.decode(username); return `nostr:${nip19.npubEncode(pubkey)}`;
if (result.type === 'npub') { } catch {
tags.push(['p', result.data]); return match;
return `nostr:${username}`;
} else {
return match;
}
} catch (_e) {
// do nothing
} }
if (NIP05.regex().test(username)) {
const pointer = await nip05Cache.fetch(username);
if (pointer) {
tags.push(['p', pointer.pubkey]);
return `nostr:${nip19.npubEncode(pointer.pubkey)}`;
}
}
return match;
}); });
// Explicit addressing
for (const to of data.to ?? []) {
const pubkey = await lookupPubkey(to);
if (pubkey) {
pubkeys.add(pubkey);
}
}
for (const pubkey of pubkeys) {
tags.push(['p', pubkey]);
}
for (const match of content.matchAll(/#(\w+)/g)) { for (const match of content.matchAll(/#(\w+)/g)) {
tags.push(['t', match[1]]); tags.push(['t', match[1]]);
} }
const mediaUrls: string[] = media
.map(({ data }) => data.find(([name]) => name === 'url')?.[1])
.filter((url): url is string => Boolean(url));
const mediaCompat: string = mediaUrls.length ? ['', '', ...mediaUrls].join('\n') : '';
const event = await createEvent({ const event = await createEvent({
kind: 1, kind: 1,
content, content: content + mediaCompat,
tags, tags,
}, c); }, c);
@ -136,17 +152,17 @@ const createStatusController: AppController = async (c) => {
if (data.quote_id) { if (data.quote_id) {
await hydrateEvents({ await hydrateEvents({
events: [event], events: [event],
storage: eventsDB, store: await Storages.db(),
signal: c.req.raw.signal, signal: c.req.raw.signal,
}); });
} }
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: c.get('pubkey') })); return c.json(await renderStatus({ ...event, author }, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
}; };
const deleteStatusController: AppController = async (c) => { const deleteStatusController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const pubkey = c.get('pubkey'); const pubkey = await c.get('signer')?.getPublicKey();
const event = await getEvent(id, { signal: c.req.raw.signal }); const event = await getEvent(id, { signal: c.req.raw.signal });
@ -170,9 +186,12 @@ const deleteStatusController: AppController = async (c) => {
const contextController: AppController = async (c) => { const contextController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
const viewerPubkey = await c.get('signer')?.getPublicKey();
async function renderStatuses(events: NostrEvent[]) { async function renderStatuses(events: NostrEvent[]) {
const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))); const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })),
);
return statuses.filter(Boolean); return statuses.filter(Boolean);
} }
@ -202,7 +221,7 @@ const favouriteController: AppController = async (c) => {
], ],
}, c); }, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') }); const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
if (status) { if (status) {
status.favourited = true; status.favourited = true;
@ -241,11 +260,11 @@ const reblogStatusController: AppController = async (c) => {
await hydrateEvents({ await hydrateEvents({
events: [reblogEvent], events: [reblogEvent],
storage: eventsDB, store: await Storages.db(),
signal: signal, signal: signal,
}); });
const status = await renderReblog(reblogEvent, { viewerPubkey: c.get('pubkey') }); const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() });
return c.json(status); return c.json(status);
}; };
@ -253,23 +272,28 @@ const reblogStatusController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */ /** https://docs.joinmastodon.org/methods/statuses/#unreblog */
const unreblogStatusController: AppController = async (c) => { const unreblogStatusController: AppController = async (c) => {
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const pubkey = c.get('pubkey') as string; const pubkey = await c.get('signer')?.getPublicKey()!;
const store = await Storages.db();
const event = await getEvent(eventId, { const [event] = await store.query([{ ids: [eventId], kinds: [1] }]);
kind: 1, if (!event) {
}); return c.json({ error: 'Record not found' }, 404);
if (!event) return c.json({ error: 'Event not found.' }, 404); }
const filters: NostrFilter[] = [{ kinds: [6], authors: [pubkey], '#e': [event.id] }]; const [repostedEvent] = await store.query(
const [repostedEvent] = await eventsDB.query(filters, { limit: 1 }); [{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
if (!repostedEvent) return c.json({ error: 'Event not found.' }, 404); );
if (!repostedEvent) {
return c.json({ error: 'Record not found' }, 404);
}
await createEvent({ await createEvent({
kind: 5, kind: 5,
tags: [['e', repostedEvent.id]], tags: [['e', repostedEvent.id]],
}, c); }, c);
return c.json(await renderStatus(event, {})); return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
}; };
const rebloggedByController: AppController = (c) => { const rebloggedByController: AppController = (c) => {
@ -280,7 +304,7 @@ const rebloggedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */ /** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => { const bookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId, { const event = await getEvent(eventId, {
@ -290,7 +314,7 @@ const bookmarkController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10003], authors: [pubkey] }, { kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', eventId]), (tags) => addTag(tags, ['e', eventId]),
c, c,
); );
@ -307,7 +331,7 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */ /** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => { const unbookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId, { const event = await getEvent(eventId, {
@ -317,7 +341,7 @@ const unbookmarkController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10003], authors: [pubkey] }, { kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', eventId]), (tags) => deleteTag(tags, ['e', eventId]),
c, c,
); );
@ -334,7 +358,7 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */ /** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => { const pinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId, { const event = await getEvent(eventId, {
@ -344,7 +368,7 @@ const pinController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10001], authors: [pubkey] }, { kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', eventId]), (tags) => addTag(tags, ['e', eventId]),
c, c,
); );
@ -361,7 +385,7 @@ const pinController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unpin */ /** https://docs.joinmastodon.org/methods/statuses/#unpin */
const unpinController: AppController = async (c) => { const unpinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -373,7 +397,7 @@ const unpinController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10001], authors: [pubkey] }, { kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', eventId]), (tags) => deleteTag(tags, ['e', eventId]),
c, c,
); );
@ -405,7 +429,7 @@ const zapController: AppController = async (c) => {
const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'], signal }); const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'], signal });
const author = target?.author; const author = target?.author;
const meta = jsonMetaContentSchema.parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta); const lnurl = getLnurl(meta);
if (target && lnurl) { if (target && lnurl) {
@ -421,7 +445,7 @@ const zapController: AppController = async (c) => {
], ],
}, c); }, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') }); const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
status.zapped = true; status.zapped = true;
return c.json(status); return c.json(status);

View File

@ -1,15 +1,15 @@
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
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 { Debug } from '@/deps.ts'; import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import { getFeedPubkeys } from '@/queries.ts'; import { getFeedPubkeys } from '@/queries.ts';
import { hydrateEvents } from '@/storages/hydrate.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 { hydrateEvents } from '@/storages/hydrate.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
const debug = Debug('ditto:streaming'); const debug = Debug('ditto:streaming');
@ -69,11 +69,24 @@ const streamingController: AppController = (c) => {
if (!filter) return; if (!filter) return;
try { try {
for await (const msg of Storages.pubsub.req([filter], { signal: controller.signal })) { const pubsub = await Storages.pubsub();
const optimizer = await Storages.optimizer();
for await (const msg of pubsub.req([filter], { signal: controller.signal })) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
const [event] = await hydrateEvents({ const event = msg[2];
events: [msg[2]],
storage: eventsDB, if (pubkey) {
const policy = new MuteListPolicy(pubkey, await Storages.admin());
const [, , ok] = await policy.call(event);
if (!ok) {
continue;
}
}
await hydrateEvents({
events: [event],
store: optimizer,
signal: AbortSignal.timeout(1000), signal: AbortSignal.timeout(1000),
}); });

View File

@ -0,0 +1,51 @@
import { NStore } from '@nostrify/nostrify';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getTagSet } from '@/tags.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => {
const store = c.get('store');
const signal = c.req.raw.signal;
const accounts = await renderSuggestedAccounts(store, signal);
return c.json(accounts);
};
export const suggestionsV2Controller: AppController = async (c) => {
const store = c.get('store');
const signal = c.req.raw.signal;
const accounts = await renderSuggestedAccounts(store, signal);
const suggestions = accounts.map((account) => ({
source: 'staff',
account,
}));
return c.json(suggestions);
};
async function renderSuggestedAccounts(store: NStore, signal?: AbortSignal) {
const [follows] = await store.query(
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }],
{ signal },
);
// TODO: pagination
const pubkeys = [...getTagSet(follows?.tags ?? [], 'p')].slice(0, 20);
const profiles = await store.query(
[{ kinds: [0], authors: pubkeys, limit: pubkeys.length }],
{ signal },
)
.then((events) => hydrateEvents({ events, store, signal }));
const accounts = await Promise.all(pubkeys.map((pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey);
return profile ? renderAccount(profile) : accountFromPubkey(pubkey);
}));
return accounts.filter(Boolean);
}

View File

@ -1,4 +1,4 @@
import { NostrFilter, NStore } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { type AppContext, type AppController } from '@/app.ts'; import { type AppContext, type AppController } from '@/app.ts';
@ -11,7 +11,7 @@ import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
const homeTimelineController: AppController = async (c) => { const homeTimelineController: AppController = async (c) => {
const params = paginationSchema.parse(c.req.query()); const params = paginationSchema.parse(c.req.query());
const pubkey = c.get('pubkey')!; const pubkey = await c.get('signer')?.getPublicKey()!;
const authors = await getFeedPubkeys(pubkey); const authors = await getFeedPubkeys(pubkey);
return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]); return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]);
}; };
@ -37,7 +37,7 @@ const publicTimelineController: AppController = (c) => {
}; };
const hashtagTimelineController: AppController = (c) => { const hashtagTimelineController: AppController = (c) => {
const hashtag = c.req.param('hashtag')!; const hashtag = c.req.param('hashtag')!.toLowerCase();
const params = paginationSchema.parse(c.req.query()); const params = paginationSchema.parse(c.req.query());
return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]); return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]);
}; };
@ -45,28 +45,24 @@ const hashtagTimelineController: AppController = (c) => {
/** Render statuses for timelines. */ /** Render statuses for timelines. */
async function renderStatuses(c: AppContext, filters: NostrFilter[]) { async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const { signal } = c.req.raw; const { signal } = c.req.raw;
const store = c.get('store') as NStore; const store = c.get('store');
const events = await store const events = await store
.query(filters, { signal }) .query(filters, { signal })
.then((events) => .then((events) => hydrateEvents({ events, store, signal }));
hydrateEvents({
events,
storage: store,
signal,
})
);
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
} }
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = (await Promise.all(events.map((event) => { const statuses = (await Promise.all(events.map((event) => {
if (event.kind === 6) { if (event.kind === 6) {
return renderReblog(event, { viewerPubkey: c.get('pubkey') }); return renderReblog(event, { viewerPubkey });
} }
return renderStatus(event, { viewerPubkey: c.get('pubkey') }); return renderStatus(event, { viewerPubkey });
}))).filter((boolean) => boolean); }))).filter(Boolean);
if (!statuses.length) { if (!statuses.length) {
return c.json([]); return c.json([]);

View File

@ -1,18 +1,17 @@
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts'; import { Storages } from '@/storages.ts';
import { eventsDB } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
const relayInfoController: AppController = async (c) => { const relayInfoController: AppController = async (c) => {
const { signal } = c.req.raw; const store = await Storages.db();
const [event] = await eventsDB.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal }); const meta = await getInstanceMetadata(store, c.req.raw.signal);
const meta = jsonServerMetaSchema.parse(event?.content);
return c.json({ return c.json({
name: meta.name ?? 'Ditto', name: meta.name,
description: meta.about ?? 'Nostr and the Fediverse.', description: meta.about,
pubkey: Conf.pubkey, pubkey: Conf.pubkey,
contact: `mailto:${meta.email ?? `postmaster@${Conf.url.host}`}`, contact: meta.email,
supported_nips: [1, 5, 9, 11, 16, 45, 50, 46, 98], supported_nips: [1, 5, 9, 11, 16, 45, 50, 46, 98],
software: 'Ditto', software: 'Ditto',
version: '0.0.0', version: '0.0.0',

View File

@ -1,15 +1,16 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
import { eventsDB } from '@/storages.ts';
import * as pipeline from '@/pipeline.ts';
import { import {
type ClientCLOSE, NostrClientCLOSE,
type ClientCOUNT, NostrClientCOUNT,
type ClientEVENT, NostrClientEVENT,
type ClientMsg, NostrClientMsg,
clientMsgSchema, NostrClientREQ,
type ClientREQ, NostrEvent,
} from '@/schemas/nostr.ts'; NostrFilter,
NSchema as n,
} from '@nostrify/nostrify';
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
import * as pipeline from '@/pipeline.ts';
import { RelayError } from '@/RelayError.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import type { AppController } from '@/app.ts'; import type { AppController } from '@/app.ts';
@ -31,7 +32,7 @@ function connectStream(socket: WebSocket) {
const controllers = new Map<string, AbortController>(); const controllers = new Map<string, AbortController>();
socket.onmessage = (e) => { socket.onmessage = (e) => {
const result = n.json().pipe(clientMsgSchema).safeParse(e.data); const result = n.json().pipe(n.clientMsg()).safeParse(e.data);
if (result.success) { if (result.success) {
handleMsg(result.data); handleMsg(result.data);
} else { } else {
@ -46,7 +47,7 @@ function connectStream(socket: WebSocket) {
}; };
/** Handle client message. */ /** Handle client message. */
function handleMsg(msg: ClientMsg) { function handleMsg(msg: NostrClientMsg) {
switch (msg[0]) { switch (msg[0]) {
case 'REQ': case 'REQ':
handleReq(msg); handleReq(msg);
@ -64,21 +65,24 @@ function connectStream(socket: WebSocket) {
} }
/** Handle REQ. Start a subscription. */ /** Handle REQ. Start a subscription. */
async function handleReq([_, subId, ...rest]: ClientREQ): Promise<void> { async function handleReq([_, subId, ...rest]: NostrClientREQ): Promise<void> {
const filters = prepareFilters(rest); const filters = prepareFilters(rest);
const controller = new AbortController(); const controller = new AbortController();
controllers.get(subId)?.abort(); controllers.get(subId)?.abort();
controllers.set(subId, controller); controllers.set(subId, controller);
for (const event of await eventsDB.query(filters, { limit: FILTER_LIMIT })) { const db = await Storages.db();
const pubsub = await Storages.pubsub();
for (const event of await db.query(filters, { limit: FILTER_LIMIT })) {
send(['EVENT', subId, event]); send(['EVENT', subId, event]);
} }
send(['EOSE', subId]); send(['EOSE', subId]);
try { try {
for await (const msg of Storages.pubsub.req(filters, { signal: controller.signal })) { for await (const msg of pubsub.req(filters, { signal: controller.signal })) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
send(['EVENT', subId, msg[2]]); send(['EVENT', subId, msg[2]]);
} }
@ -89,13 +93,13 @@ function connectStream(socket: WebSocket) {
} }
/** Handle EVENT. Store the event. */ /** Handle EVENT. Store the event. */
async function handleEvent([_, event]: ClientEVENT): Promise<void> { async function handleEvent([_, event]: NostrClientEVENT): Promise<void> {
try { try {
// This will store it (if eligible) and run other side-effects. // This will store it (if eligible) and run other side-effects.
await pipeline.handleEvent(event, AbortSignal.timeout(1000)); await pipeline.handleEvent(event, AbortSignal.timeout(1000));
send(['OK', event.id, true, '']); send(['OK', event.id, true, '']);
} catch (e) { } catch (e) {
if (e instanceof pipeline.RelayError) { if (e instanceof RelayError) {
send(['OK', event.id, false, e.message]); send(['OK', event.id, false, e.message]);
} else { } else {
send(['OK', event.id, false, 'error: something went wrong']); send(['OK', event.id, false, 'error: something went wrong']);
@ -105,7 +109,7 @@ function connectStream(socket: WebSocket) {
} }
/** Handle CLOSE. Close the subscription. */ /** Handle CLOSE. Close the subscription. */
function handleClose([_, subId]: ClientCLOSE): void { function handleClose([_, subId]: NostrClientCLOSE): void {
const controller = controllers.get(subId); const controller = controllers.get(subId);
if (controller) { if (controller) {
controller.abort(); controller.abort();
@ -114,8 +118,9 @@ function connectStream(socket: WebSocket) {
} }
/** Handle COUNT. Return the number of events matching the filters. */ /** Handle COUNT. Return the number of events matching the filters. */
async function handleCount([_, subId, ...rest]: ClientCOUNT): Promise<void> { async function handleCount([_, subId, ...rest]: NostrClientCOUNT): Promise<void> {
const { count } = await eventsDB.count(prepareFilters(rest)); const store = await Storages.db();
const { count } = await store.count(prepareFilters(rest));
send(['COUNT', subId, { count, approximate: false }]); send(['COUNT', subId, { count, approximate: false }]);
} }
@ -128,7 +133,7 @@ function connectStream(socket: WebSocket) {
} }
/** Enforce the filters with certain criteria. */ /** Enforce the filters with certain criteria. */
function prepareFilters(filters: ClientREQ[2][]): NostrFilter[] { function prepareFilters(filters: NostrClientREQ[2][]): NostrFilter[] {
return filters.map((filter) => { return filters.map((filter) => {
const narrow = Boolean(filter.ids?.length || filter.authors?.length); const narrow = Boolean(filter.ids?.length || filter.authors?.length);
const search = narrow ? filter.search : `domain:${Conf.url.host} ${filter.search ?? ''}`; const search = narrow ? filter.search : `domain:${Conf.url.host} ${filter.search ?? ''}`;

View File

@ -12,7 +12,7 @@ const nameSchema = z.string().min(1).regex(/^\w+$/);
const nostrController: AppController = async (c) => { const nostrController: AppController = async (c) => {
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(name) : undefined; const pointer = name ? await localNip05Lookup(c.get('store'), name) : undefined;
if (!name || !pointer) { if (!name || !pointer) {
return c.json({ names: {}, relays: {} }); return c.json({ names: {}, relays: {} });

View File

@ -1,7 +1,7 @@
import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { nip19 } from '@/deps.ts';
import { localNip05Lookup } from '@/utils/nip05.ts'; import { localNip05Lookup } from '@/utils/nip05.ts';
import type { AppContext, AppController } from '@/app.ts'; import type { AppContext, AppController } from '@/app.ts';
@ -45,7 +45,7 @@ async function handleAcct(c: AppContext, resource: URL): Promise<Response> {
} }
const [username, host] = result.data; const [username, host] = result.data;
const pointer = await localNip05Lookup(username); const pointer = await localNip05Lookup(c.get('store'), username);
if (!pointer) { if (!pointer) {
return c.json({ error: 'Not found' }, 404); return c.json({ error: 'Not found' }, 404);

View File

@ -1,29 +0,0 @@
import { deleteUnattachedMediaByUrl, getUnattachedMedia } from '@/db/unattached-media.ts';
import { cron } from '@/deps.ts';
import { Time } from '@/utils/time.ts';
import { configUploader as uploader } from '@/uploaders/config.ts';
import { cidFromUrl } from '@/utils/ipfs.ts';
/** Delete files that aren't attached to any events. */
async function cleanupMedia() {
console.info('Deleting orphaned media files...');
const until = new Date(Date.now() - Time.minutes(15));
const media = await getUnattachedMedia(until);
for (const { url } of media) {
const cid = cidFromUrl(new URL(url))!;
try {
await uploader.delete(cid);
await deleteUnattachedMediaByUrl(url);
} catch (e) {
console.error(`Failed to delete file ${url}`);
console.error(e);
}
}
console.info(`Removed ${media?.length ?? 0} orphaned media files.`);
}
await cleanupMedia();
cron.every15Minute(cleanupMedia);

View File

@ -1,40 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { DittoDB } from '@/db/DittoDB.ts';
import { FileMigrationProvider, Migrator } from '@/deps.ts';
const db = await DittoDB.getInstance();
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: new URL(import.meta.resolve('./db/migrations')).pathname,
}),
});
/** Migrate the database to the latest version. */
async function migrate() {
console.info('Running migrations...');
const results = await migrator.migrateToLatest();
if (results.error) {
console.error(results.error);
Deno.exit(1);
} else {
if (!results.results?.length) {
console.info('Everything up-to-date.');
} else {
console.info('Migrations finished!');
for (const { migrationName, status } of results.results!) {
console.info(` - ${migrationName}: ${status}`);
}
}
}
}
await migrate();
export { db };

View File

@ -1,21 +1,71 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoPostgres } from '@/db/adapters/DittoPostgres.ts'; import { DittoPostgres } from '@/db/adapters/DittoPostgres.ts';
import { DittoSQLite } from '@/db/adapters/DittoSQLite.ts'; import { DittoSQLite } from '@/db/adapters/DittoSQLite.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { Kysely } from '@/deps.ts';
export class DittoDB { export class DittoDB {
private static kysely: Promise<Kysely<DittoTables>> | undefined;
static getInstance(): Promise<Kysely<DittoTables>> { static getInstance(): Promise<Kysely<DittoTables>> {
if (!this.kysely) {
this.kysely = this._getInstance();
}
return this.kysely;
}
static async _getInstance(): Promise<Kysely<DittoTables>> {
const { databaseUrl } = Conf; const { databaseUrl } = Conf;
let kysely: Kysely<DittoTables>;
switch (databaseUrl.protocol) { switch (databaseUrl.protocol) {
case 'sqlite:': case 'sqlite:':
return DittoSQLite.getInstance(); kysely = await DittoSQLite.getInstance();
break;
case 'postgres:': case 'postgres:':
case 'postgresql:': case 'postgresql:':
return DittoPostgres.getInstance(); kysely = await DittoPostgres.getInstance();
break;
default: default:
throw new Error('Unsupported database URL.'); throw new Error('Unsupported database URL.');
} }
await this.migrate(kysely);
return kysely;
}
/** Migrate the database to the latest version. */
static async migrate(kysely: Kysely<DittoTables>) {
const migrator = new Migrator({
db: kysely,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: new URL(import.meta.resolve('../db/migrations')).pathname,
}),
});
console.info('Running migrations...');
const results = await migrator.migrateToLatest();
if (results.error) {
console.error(results.error);
Deno.exit(1);
} else {
if (!results.results?.length) {
console.info('Everything up-to-date.');
} else {
console.info('Migrations finished!');
for (const { migrationName, status } of results.results!) {
console.info(` - ${migrationName}: ${status}`);
}
}
}
} }
} }

View File

@ -1,8 +1,7 @@
export interface DittoTables { export interface DittoTables {
events: EventRow; nostr_events: EventRow;
events_fts: EventFTSRow; nostr_tags: TagRow;
tags: TagRow; nostr_fts5: EventFTSRow;
relays: RelayRow;
unattached_media: UnattachedMediaRow; unattached_media: UnattachedMediaRow;
author_stats: AuthorStatsRow; author_stats: AuthorStatsRow;
event_stats: EventStatsRow; event_stats: EventStatsRow;
@ -31,24 +30,17 @@ interface EventRow {
created_at: number; created_at: number;
tags: string; tags: string;
sig: string; sig: string;
deleted_at: number | null;
} }
interface EventFTSRow { interface EventFTSRow {
id: string; event_id: string;
content: string; content: string;
} }
interface TagRow { interface TagRow {
tag: string;
value: string;
event_id: string; event_id: string;
} name: string;
value: string;
interface RelayRow {
url: string;
domain: string;
active: boolean;
} }
interface UnattachedMediaRow { interface UnattachedMediaRow {

18
src/db/KyselyLogger.ts Normal file
View File

@ -0,0 +1,18 @@
import { Stickynotes } from '@soapbox/stickynotes';
import { Logger } from 'kysely';
/** Log the SQL for queries. */
export const KyselyLogger: Logger = (event) => {
if (event.level === 'query') {
const console = new Stickynotes('ditto:sql');
const { query, queryDurationMillis } = event;
const { sql, parameters } = query;
console.debug(
sql,
JSON.stringify(parameters),
`\x1b[90m(${(queryDurationMillis / 1000).toFixed(2)}s)\x1b[0m`,
);
}
};

View File

@ -1,7 +1,9 @@
import { Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely'; import { Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
import { PostgreSQLDriver } from 'kysely_deno_postgres'; import { PostgreSQLDriver } from 'kysely_deno_postgres';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts';
export class DittoPostgres { export class DittoPostgres {
static db: Kysely<DittoTables> | undefined; static db: Kysely<DittoTables> | undefined;
@ -16,9 +18,10 @@ export class DittoPostgres {
}, },
// @ts-ignore mismatched kysely versions probably // @ts-ignore mismatched kysely versions probably
createDriver() { createDriver() {
return new PostgreSQLDriver({ return new PostgreSQLDriver(
connectionString: Deno.env.get('DATABASE_URL'), { connectionString: Deno.env.get('DATABASE_URL') },
}); Conf.pg.poolSize,
);
}, },
createIntrospector(db: Kysely<unknown>) { createIntrospector(db: Kysely<unknown>) {
return new PostgresIntrospector(db); return new PostgresIntrospector(db);
@ -27,6 +30,7 @@ export class DittoPostgres {
return new PostgresQueryCompiler(); return new PostgresQueryCompiler();
}, },
}, },
log: KyselyLogger,
}); });
} }

View File

@ -1,6 +1,9 @@
import { PolySqliteDialect } from '@soapbox/kysely-deno-sqlite';
import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { Kysely, PolySqliteDialect, sql } from '@/deps.ts'; import { KyselyLogger } from '@/db/KyselyLogger.ts';
import SqliteWorker from '@/workers/sqlite.ts'; import SqliteWorker from '@/workers/sqlite.ts';
export class DittoSQLite { export class DittoSQLite {
@ -15,6 +18,7 @@ export class DittoSQLite {
dialect: new PolySqliteDialect({ dialect: new PolySqliteDialect({
database: sqliteWorker, database: sqliteWorker,
}), }),
log: KyselyLogger,
}); });
// Set PRAGMA values. // Set PRAGMA values.

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -1,5 +1,6 @@
import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { Kysely, sql } from '@/deps.ts';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
if (Conf.databaseUrl.protocol === 'sqlite:') { if (Conf.databaseUrl.protocol === 'sqlite:') {

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(_db: Kysely<any>): Promise<void> { export async function up(_db: Kysely<any>): Promise<void> {
} }

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(_db: Kysely<any>): Promise<void> { export async function up(_db: Kysely<any>): Promise<void> {
} }

View File

@ -1,4 +1,4 @@
import { Kysely, sql } from '@/deps.ts'; import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(_db: Kysely<any>): Promise<void> { export async function up(_db: Kysely<any>): Promise<void> {
} }

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(_db: Kysely<any>): Promise<void> { export async function up(_db: Kysely<any>): Promise<void> {
} }

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').ifExists().execute(); await db.schema.dropTable('users').ifExists().execute();

View File

@ -1,10 +1,10 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema
.createIndex('idx_events_kind_pubkey_created_at') .createIndex('idx_events_kind_pubkey_created_at')
.on('events') .on('events')
.columns(['kind', 'pubkey', 'created_at']) .columns(['kind', 'pubkey', 'created_at desc'])
.execute(); .execute();
} }

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_tags_tag').execute(); await db.schema.dropIndex('idx_tags_tag').execute();

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('events').addColumn('deleted_at', 'integer').execute(); await db.schema.alterTable('events').addColumn('deleted_at', 'integer').execute();

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema.createIndex('idx_author_stats_pubkey').on('author_stats').column('pubkey').execute(); await db.schema.createIndex('idx_author_stats_pubkey').on('author_stats').column('pubkey').execute();

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts'; import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema await db.schema

View File

@ -0,0 +1,14 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('relays').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('relays')
.addColumn('url', 'text', (col) => col.primaryKey())
.addColumn('domain', 'text', (col) => col.notNull())
.addColumn('active', 'boolean', (col) => col.notNull())
.execute();
}

View File

@ -0,0 +1,14 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_events_created_at_kind')
.on('events')
.columns(['created_at desc', 'kind'])
.ifNotExists()
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_events_created_at_kind').ifExists().execute();
}

View File

@ -0,0 +1,25 @@
import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('events').renameTo('nostr_events').execute();
await db.schema.alterTable('tags').renameTo('nostr_tags').execute();
await db.schema.alterTable('nostr_tags').renameColumn('tag', 'name').execute();
if (Conf.databaseUrl.protocol === 'sqlite:') {
await db.schema.dropTable('events_fts').execute();
await sql`CREATE VIRTUAL TABLE nostr_fts5 USING fts5(event_id, content)`.execute(db);
}
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('nostr_events').renameTo('events').execute();
await db.schema.alterTable('nostr_tags').renameTo('tags').execute();
await db.schema.alterTable('tags').renameColumn('name', 'tag').execute();
if (Conf.databaseUrl.protocol === 'sqlite:') {
await db.schema.dropTable('nostr_fts5').execute();
await sql`CREATE VIRTUAL TABLE events_fts USING fts5(id, content)`.execute(db);
}
}

View File

@ -0,0 +1,10 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.deleteFrom('nostr_events').where('deleted_at', 'is not', null).execute();
await db.schema.alterTable('nostr_events').dropColumn('deleted_at').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('nostr_events').addColumn('deleted_at', 'integer').execute();
}

View File

@ -0,0 +1,19 @@
import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts';
export async function up(db: Kysely<any>): Promise<void> {
if (['postgres:', 'postgresql:'].includes(Conf.databaseUrl.protocol!)) {
await db.schema.createTable('nostr_pgfts')
.ifNotExists()
.addColumn('event_id', 'text', (c) => c.primaryKey().references('nostr_events.id').onDelete('cascade'))
.addColumn('search_vec', sql`tsvector`, (c) => c.notNull())
.execute();
}
}
export async function down(db: Kysely<any>): Promise<void> {
if (['postgres:', 'postgresql:'].includes(Conf.databaseUrl.protocol!)) {
await db.schema.dropTable('nostr_pgfts').ifExists().execute();
}
}

View File

@ -1,36 +0,0 @@
import { tldts } from '@/deps.ts';
import { db } from '@/db.ts';
interface AddRelaysOpts {
active?: boolean;
}
/** Inserts relays into the database, skipping duplicates. */
function addRelays(relays: `wss://${string}`[], opts: AddRelaysOpts = {}) {
if (!relays.length) return Promise.resolve();
const { active = false } = opts;
const values = relays.map((url) => ({
url: new URL(url).toString(),
domain: tldts.getDomain(url)!,
active,
}));
return db.insertInto('relays')
.values(values)
.onConflict((oc) => oc.column('url').doNothing())
.execute();
}
/** Get a list of all known active relay URLs. */
async function getActiveRelays(): Promise<string[]> {
const rows = await db
.selectFrom('relays')
.select('relays.url')
.where('relays.active', '=', true)
.execute();
return rows.map((row) => row.url);
}
export { addRelays, getActiveRelays };

View File

@ -1,33 +1,30 @@
import { db } from '@/db.ts'; import { Kysely } from 'kysely';
import { uuid62 } from '@/deps.ts';
import { type MediaData } from '@/schemas/nostr.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { DittoTables } from '@/db/DittoTables.ts';
interface UnattachedMedia { interface UnattachedMedia {
id: string; id: string;
pubkey: string; pubkey: string;
url: string; url: string;
data: MediaData; /** NIP-94 tags. */
data: string[][];
uploaded_at: number; uploaded_at: number;
} }
/** Add unattached media into the database. */ /** Add unattached media into the database. */
async function insertUnattachedMedia(media: Omit<UnattachedMedia, 'id' | 'uploaded_at'>) { async function insertUnattachedMedia(media: UnattachedMedia) {
const result = { const kysely = await DittoDB.getInstance();
id: uuid62.v4(), await kysely.insertInto('unattached_media')
uploaded_at: Date.now(), .values({ ...media, data: JSON.stringify(media.data) })
...media,
};
await db.insertInto('unattached_media')
.values({ ...result, data: JSON.stringify(media.data) })
.execute(); .execute();
return result; return media;
} }
/** Select query for unattached media. */ /** Select query for unattached media. */
function selectUnattachedMediaQuery() { function selectUnattachedMediaQuery(kysely: Kysely<DittoTables>) {
return db.selectFrom('unattached_media') return kysely.selectFrom('unattached_media')
.select([ .select([
'unattached_media.id', 'unattached_media.id',
'unattached_media.pubkey', 'unattached_media.pubkey',
@ -38,30 +35,40 @@ function selectUnattachedMediaQuery() {
} }
/** Find attachments that exist but aren't attached to any events. */ /** Find attachments that exist but aren't attached to any events. */
function getUnattachedMedia(until: Date) { function getUnattachedMedia(kysely: Kysely<DittoTables>, until: Date) {
return selectUnattachedMediaQuery() return selectUnattachedMediaQuery(kysely)
.leftJoin('tags', 'unattached_media.url', 'tags.value') .leftJoin('nostr_tags', 'unattached_media.url', 'nostr_tags.value')
.where('uploaded_at', '<', until.getTime()) .where('uploaded_at', '<', until.getTime())
.execute(); .execute();
} }
/** Delete unattached media by URL. */ /** Delete unattached media by URL. */
function deleteUnattachedMediaByUrl(url: string) { async function deleteUnattachedMediaByUrl(url: string) {
return db.deleteFrom('unattached_media') const kysely = await DittoDB.getInstance();
return kysely.deleteFrom('unattached_media')
.where('url', '=', url) .where('url', '=', url)
.execute(); .execute();
} }
/** Get unattached media by IDs. */ /** Get unattached media by IDs. */
function getUnattachedMediaByIds(ids: string[]) { async function getUnattachedMediaByIds(kysely: Kysely<DittoTables>, ids: string[]): Promise<UnattachedMedia[]> {
return selectUnattachedMediaQuery() if (!ids.length) return [];
const results = await selectUnattachedMediaQuery(kysely)
.where('id', 'in', ids) .where('id', 'in', ids)
.execute(); .execute();
return results.map((row) => ({
...row,
data: JSON.parse(row.data),
}));
} }
/** Delete rows as an event with media is being created. */ /** Delete rows as an event with media is being created. */
function deleteAttachedMedia(pubkey: string, urls: string[]) { async function deleteAttachedMedia(pubkey: string, urls: string[]): Promise<void> {
return db.deleteFrom('unattached_media') if (!urls.length) return;
const kysely = await DittoDB.getInstance();
await kysely.deleteFrom('unattached_media')
.where('pubkey', '=', pubkey) .where('pubkey', '=', pubkey)
.where('url', 'in', urls) .where('url', 'in', urls)
.execute(); .execute();

View File

@ -1,9 +1,10 @@
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { Debug } from '@/deps.ts';
import * as pipeline from '@/pipeline.ts'; import * as pipeline from '@/pipeline.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
const debug = Debug('ditto:users'); const debug = Debug('ditto:users');
@ -59,7 +60,8 @@ async function findUser(user: Partial<User>, signal?: AbortSignal): Promise<User
} }
} }
const [event] = await eventsDB.query([filter], { signal }); const store = await Storages.db();
const [event] = await store.query([filter], { signal });
if (event) { if (event) {
return { return {

View File

@ -1 +0,0 @@
export { assert, assertEquals, assertRejects, assertThrows } from 'https://deno.land/std@0.198.0/assert/mod.ts';

View File

@ -1,32 +1,8 @@
import 'https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts'; import 'deno-safe-fetch';
export { RelayPoolWorker } from 'npm:nostr-relaypool2@0.6.34';
export {
type EventTemplate,
getEventHash,
matchFilter,
matchFilters,
nip05,
nip13,
nip19,
nip21,
type UnsignedEvent,
type VerifiedEvent,
} from 'npm:nostr-tools@^2.3.1';
export { finalizeEvent, getPublicKey, verifyEvent } from 'npm:nostr-tools@^2.3.1/wasm';
export { parseFormData } from 'npm:formdata-helper@^0.3.0';
// @deno-types="npm:@types/lodash@4.14.194" // @deno-types="npm:@types/lodash@4.14.194"
export { default as lodash } from 'https://esm.sh/lodash@4.17.21'; export { default as lodash } from 'https://esm.sh/lodash@4.17.21';
export { default as linkify } from 'npm:linkifyjs@^4.1.1';
export { default as linkifyStr } from 'npm:linkify-string@^4.1.1';
import 'npm:linkify-plugin-hashtag@^4.1.1';
// @deno-types="npm:@types/mime@3.0.0"
export { default as mime } from 'npm:mime@^3.0.0';
export { unfurl } from 'npm:unfurl.js@^6.4.0';
export { default as TTLCache } from 'npm:@isaacs/ttlcache@^1.4.1';
// @deno-types="npm:@types/sanitize-html@2.9.0" // @deno-types="npm:@types/sanitize-html@2.9.0"
export { default as sanitizeHtml } from 'npm:sanitize-html@^2.11.0'; export { default as sanitizeHtml } from 'npm:sanitize-html@^2.11.0';
export { default as ISO6391 } from 'npm:iso-639-1@2.1.15';
export { createPentagon } from 'https://deno.land/x/pentagon@v0.1.4/mod.ts';
export { export {
type ParsedSignature, type ParsedSignature,
pemToPublicKey, pemToPublicKey,
@ -35,44 +11,6 @@ export {
verifyRequest, verifyRequest,
} from 'https://gitlab.com/soapbox-pub/fedisign/-/raw/v0.2.1/mod.ts'; } from 'https://gitlab.com/soapbox-pub/fedisign/-/raw/v0.2.1/mod.ts';
export { generateSeededRsa } from 'https://gitlab.com/soapbox-pub/seeded-rsa/-/raw/v1.0.0/mod.ts'; export { generateSeededRsa } from 'https://gitlab.com/soapbox-pub/seeded-rsa/-/raw/v1.0.0/mod.ts';
export * as secp from 'npm:@noble/secp256k1@^2.0.0';
export { LRUCache } from 'npm:lru-cache@^10.2.0';
export { export {
DB as Sqlite, DB as Sqlite,
SqliteError,
} from 'https://raw.githubusercontent.com/alexgleason/deno-sqlite/325f66d8c395e7f6f5ee78ebfa42a0eeea4a942b/mod.ts'; } from 'https://raw.githubusercontent.com/alexgleason/deno-sqlite/325f66d8c395e7f6f5ee78ebfa42a0eeea4a942b/mod.ts';
export { Database as DenoSqlite3 } from 'https://deno.land/x/sqlite3@0.9.1/mod.ts';
export * as dotenv from 'https://deno.land/std@0.198.0/dotenv/mod.ts';
export {
type CompiledQuery,
FileMigrationProvider,
type Insertable,
type InsertQueryBuilder,
Kysely,
Migrator,
type NullableInsertKeys,
type QueryResult,
type SelectQueryBuilder,
sql,
} from 'npm:kysely@^0.26.3';
export { PolySqliteDialect } from 'https://gitlab.com/soapbox-pub/kysely-deno-sqlite/-/raw/v2.0.0/mod.ts';
export { default as tldts } from 'npm:tldts@^6.0.14';
export * as cron from 'https://deno.land/x/deno_cron@v1.0.0/cron.ts';
export { S3Client } from 'https://deno.land/x/s3_lite_client@0.6.1/mod.ts';
export { default as IpfsHash } from 'npm:ipfs-only-hash@^4.0.0';
export { default as uuid62 } from 'npm:uuid62@^1.0.2';
export { Machina } from 'https://gitlab.com/soapbox-pub/nostr-machina/-/raw/08a157d39f2741c9a3a4364cb97db36e71d8c03a/mod.ts';
export * as Sentry from 'https://deno.land/x/sentry@7.78.0/index.js';
export { sentry as sentryMiddleware } from 'npm:@hono/sentry@^1.0.0';
export * as Comlink from 'npm:comlink@^4.4.1';
export { EventEmitter } from 'npm:tseep@^1.1.3';
export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0';
export { default as Debug } from 'https://gitlab.com/soapbox-pub/stickynotes/-/raw/v0.3.0/debug.ts';
export { Stickynotes } from 'https://gitlab.com/soapbox-pub/stickynotes/-/raw/v0.3.0/mod.ts';
export type * as TypeFest from 'npm:type-fest@^4.3.0';
import { setNostrWasm } from 'npm:nostr-tools@^2.3.1/wasm';
import { initNostrWasm } from 'npm:nostr-wasm@^0.1.0';
await initNostrWasm().then(setNostrWasm);

View File

@ -1,4 +1,4 @@
import { assertEquals } from '@/deps-test.ts'; import { assertEquals } from '@std/assert';
import event0 from '~/fixtures/events/event-0.json' with { type: 'json' }; import event0 from '~/fixtures/events/event-0.json' with { type: 'json' };
import event1 from '~/fixtures/events/event-1.json' with { type: 'json' }; import event1 from '~/fixtures/events/event-1.json' with { type: 'json' };

View File

@ -1,10 +1,8 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import stringifyStable from 'fast-stable-stringify';
import { getFilterLimit } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { stringifyStable } from '@/deps.ts';
import { isReplaceableKind } from '@/kinds.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Microfilter to get one specific event by ID. */ /** Microfilter to get one specific event by ID. */
type IdMicrofilter = { ids: [NostrEvent['id']] }; type IdMicrofilter = { ids: [NostrEvent['id']] };
/** Microfilter to get an author. */ /** Microfilter to get an author. */
@ -42,8 +40,8 @@ function getMicroFilters(event: NostrEvent): MicroFilter[] {
/** Microfilter schema. */ /** Microfilter schema. */
const microFilterSchema = z.union([ const microFilterSchema = z.union([
z.object({ ids: z.tuple([nostrIdSchema]) }).strict(), z.object({ ids: z.tuple([n.id()]) }).strict(),
z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([nostrIdSchema]) }).strict(), z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([n.id()]) }).strict(),
]); ]);
/** Checks whether the filter is a microfilter. */ /** Checks whether the filter is a microfilter. */
@ -51,22 +49,6 @@ function isMicrofilter(filter: NostrFilter): filter is MicroFilter {
return microFilterSchema.safeParse(filter).success; return microFilterSchema.safeParse(filter).success;
} }
/** Calculate the intrinsic limit of a filter. */
function getFilterLimit(filter: NostrFilter): number {
if (filter.ids && !filter.ids.length) return 0;
if (filter.kinds && !filter.kinds.length) return 0;
if (filter.authors && !filter.authors.length) return 0;
return Math.min(
Math.max(0, filter.limit ?? Infinity),
filter.ids?.length ?? Infinity,
filter.authors?.length &&
filter.kinds?.every((kind) => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length
: Infinity,
);
}
/** Returns true if the filter could potentially return any stored events at all. */ /** Returns true if the filter could potentially return any stored events at all. */
function canFilter(filter: NostrFilter): boolean { function canFilter(filter: NostrFilter): boolean {
return getFilterLimit(filter) > 0; return getFilterLimit(filter) > 0;

View File

@ -1,28 +1,28 @@
import { NostrEvent } from '@nostrify/nostrify'; import { Stickynotes } from '@soapbox/stickynotes';
import { Debug } from '@/deps.ts';
import { activeRelays, pool } from '@/pool.ts'; import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import * as pipeline from './pipeline.ts'; import * as pipeline from './pipeline.ts';
const debug = Debug('ditto:firehose'); const console = new Stickynotes('ditto:firehose');
// This file watches events on all known relays and performs /**
// side-effects based on them, such as trending hashtag tracking * This function watches events on all known relays and performs
// and storing events for notifications and the home feed. * side-effects based on them, such as trending hashtag tracking
pool.subscribe( * and storing events for notifications and the home feed.
[{ kinds: [0, 1, 3, 5, 6, 7, 9735, 10002], limit: 0, since: nostrNow() }], */
activeRelays, export async function startFirehose() {
handleEvent, const store = await Storages.client();
undefined,
undefined,
);
/** Handle events through the firehose pipeline. */ for await (const msg of store.req([{ kinds: [0, 1, 3, 5, 6, 7, 9735, 10002], limit: 0, since: nostrNow() }])) {
function handleEvent(event: NostrEvent): Promise<void> { if (msg[0] === 'EVENT') {
debug(`NostrEvent<${event.kind}> ${event.id}`); const event = msg[2];
console.debug(`NostrEvent<${event.kind}> ${event.id}`);
return pipeline pipeline
.handleEvent(event, AbortSignal.timeout(5000)) .handleEvent(event, AbortSignal.timeout(5000))
.catch(() => {}); .catch(() => {});
}
}
} }

View File

@ -23,5 +23,15 @@ export interface DittoEvent extends NostrEvent {
d_author?: DittoEvent; d_author?: DittoEvent;
user?: DittoEvent; user?: DittoEvent;
repost?: DittoEvent; repost?: DittoEvent;
quote_repost?: DittoEvent; quote?: DittoEvent;
reacted?: DittoEvent;
/** The profile being reported.
* Must be a kind 0 hydrated.
* https://github.com/nostr-protocol/nips/blob/master/56.md
*/
reported_profile?: DittoEvent;
/** The notes being reported.
* https://github.com/nostr-protocol/nips/blob/master/56.md
*/
reported_notes?: DittoEvent[];
} }

View File

@ -1,46 +0,0 @@
/** Events are **regular**, which means they're all expected to be stored by relays. */
function isRegularKind(kind: number) {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind);
}
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
function isReplaceableKind(kind: number) {
return (10000 <= kind && kind < 20000) || [0, 3].includes(kind);
}
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
function isEphemeralKind(kind: number) {
return 20000 <= kind && kind < 30000;
}
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
function isParameterizedReplaceableKind(kind: number) {
return 30000 <= kind && kind < 40000;
}
/** These events are only valid if published by the server keypair. */
function isDittoInternalKind(kind: number) {
return kind === 30361;
}
/** Classification of the event kind. */
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown';
/** Determine the classification of this kind of event if known, or `unknown`. */
function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular';
if (isReplaceableKind(kind)) return 'replaceable';
if (isEphemeralKind(kind)) return 'ephemeral';
if (isParameterizedReplaceableKind(kind)) return 'parameterized';
return 'unknown';
}
export {
classifyKind,
isDittoInternalKind,
isEphemeralKind,
isParameterizedReplaceableKind,
isRegularKind,
isReplaceableKind,
type KindClassification,
};

View File

@ -1,48 +0,0 @@
import { HTTPException } from 'hono';
import { type AppMiddleware } from '@/app.ts';
import { getPublicKey, nip19 } from '@/deps.ts';
/** We only accept "Bearer" type. */
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
/** NIP-19 auth middleware. */
const auth19: AppMiddleware = async (c, next) => {
const authHeader = c.req.header('authorization');
const match = authHeader?.match(BEARER_REGEX);
if (match) {
const [_, bech32] = match;
try {
const decoded = nip19.decode(bech32!);
switch (decoded.type) {
case 'npub':
c.set('pubkey', decoded.data);
break;
case 'nprofile':
c.set('pubkey', decoded.data.pubkey);
break;
case 'nsec':
c.set('pubkey', getPublicKey(decoded.data));
c.set('seckey', decoded.data);
break;
}
} catch (_e) {
//
}
}
await next();
};
/** Throw a 401 if the pubkey isn't set. */
const requirePubkey: AppMiddleware = async (c, next) => {
if (!c.get('pubkey')) {
throw new HTTPException(401, { message: 'No pubkey provided' });
}
await next();
};
export { auth19, requirePubkey };

View File

@ -1,27 +1,28 @@
import { NostrEvent } from '@nostrify/nostrify'; 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 { ConnectSigner } from '@/signers/ConnectSigner.ts';
import { localRequest } from '@/utils/api.ts';
import { import {
buildAuthEventTemplate, buildAuthEventTemplate,
parseAuthRequest, parseAuthRequest,
type ParseAuthRequestOpts, type ParseAuthRequestOpts,
validateAuthEvent, validateAuthEvent,
} from '@/utils/nip98.ts'; } from '@/utils/nip98.ts';
import { localRequest } from '@/utils/api.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { findUser, User } from '@/db/users.ts';
/** /**
* NIP-98 auth. * NIP-98 auth.
* https://github.com/nostr-protocol/nips/blob/master/98.md * https://github.com/nostr-protocol/nips/blob/master/98.md
*/ */
function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware { function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware {
return async (c, next) => { return async (c, next) => {
const req = localRequest(c); const req = localRequest(c);
const result = await parseAuthRequest(req, opts); const result = await parseAuthRequest(req, opts);
if (result.success) { if (result.success) {
c.set('pubkey', result.data.pubkey); c.set('signer', new ConnectSigner(result.data.pubkey));
c.set('proof', result.data); c.set('proof', result.data);
} }
@ -33,9 +34,8 @@ 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 user = await findUser({ pubkey: proof.pubkey });
c.set('user', user);
if (user && matchesRole(user, role)) { if (user && matchesRole(user, role)) {
await next(); await next();
@ -70,7 +70,7 @@ function withProof(
opts?: ParseAuthRequestOpts, opts?: ParseAuthRequestOpts,
): AppMiddleware { ): AppMiddleware {
return async (c, next) => { return async (c, next) => {
const pubkey = c.get('pubkey'); const pubkey = await c.get('signer')?.getPublicKey();
const proof = c.get('proof') || await obtainProof(c, opts); const proof = c.get('proof') || await obtainProof(c, opts);
// Prevent people from accidentally using the wrong account. This has no other security implications. // Prevent people from accidentally using the wrong account. This has no other security implications.
@ -79,7 +79,7 @@ function withProof(
} }
if (proof) { if (proof) {
c.set('pubkey', proof.pubkey); c.set('signer', new ConnectSigner(proof.pubkey));
c.set('proof', proof); c.set('proof', proof);
await handler(c, proof, next); await handler(c, proof, next);
} else { } else {
@ -90,9 +90,16 @@ function withProof(
/** Get the proof over Nostr Connect. */ /** Get the proof over Nostr Connect. */
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
const signer = c.get('signer');
if (!signer) {
throw new HTTPException(401, {
res: c.json({ error: 'No way to sign Nostr event' }, 401),
});
}
const req = localRequest(c); const req = localRequest(c);
const reqEvent = await buildAuthEventTemplate(req, opts); const reqEvent = await buildAuthEventTemplate(req, opts);
const resEvent = await new APISigner(c).signEvent(reqEvent); const resEvent = await signer.signEvent(reqEvent);
const result = await validateAuthEvent(req, resEvent, opts); const result = await validateAuthEvent(req, resEvent, opts);
if (result.success) { if (result.success) {
@ -100,4 +107,4 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
} }
} }
export { auth98, requireProof, requireRole }; export { auth98Middleware, requireProof, requireRole };

View File

@ -1,10 +1,11 @@
import Debug from '@soapbox/stickynotes/debug';
import { type MiddlewareHandler } from 'hono'; import { type MiddlewareHandler } from 'hono';
import { Debug } from '@/deps.ts';
import ExpiringCache from '@/utils/expiring-cache.ts'; import ExpiringCache from '@/utils/expiring-cache.ts';
const debug = Debug('ditto:middleware:cache'); const debug = Debug('ditto:middleware:cache');
export const cache = (options: { export const cacheMiddleware = (options: {
cacheName: string; cacheName: string;
expires?: number; expires?: number;
}): MiddlewareHandler => { }): MiddlewareHandler => {

View File

@ -1,7 +1,7 @@
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
const csp = (): AppMiddleware => { export const cspMiddleware = (): AppMiddleware => {
return async (c, next) => { return async (c, next) => {
const { host, protocol, origin } = Conf.url; const { host, protocol, origin } = Conf.url;
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -26,5 +26,3 @@ const csp = (): AppMiddleware => {
await next(); await next();
}; };
}; };
export { csp };

View File

@ -0,0 +1,12 @@
import { HTTPException } from 'hono';
import { AppMiddleware } from '@/app.ts';
/** Throw a 401 if a signer isn't set. */
export const requireSigner: AppMiddleware = async (c, next) => {
if (!c.get('signer')) {
throw new HTTPException(401, { message: 'No pubkey provided' });
}
await next();
};

View File

@ -0,0 +1,41 @@
import { NSecSigner } from '@nostrify/nostrify';
import { Stickynotes } from '@soapbox/stickynotes';
import { nip19 } from 'nostr-tools';
import { AppMiddleware } from '@/app.ts';
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
const console = new Stickynotes('ditto:signerMiddleware');
/** We only accept "Bearer" type. */
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
/** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */
export const signerMiddleware: AppMiddleware = async (c, next) => {
const header = c.req.header('authorization');
const match = header?.match(BEARER_REGEX);
if (match) {
const [_, bech32] = match;
try {
const decoded = nip19.decode(bech32!);
switch (decoded.type) {
case 'npub':
c.set('signer', new ConnectSigner(decoded.data));
break;
case 'nprofile':
c.set('signer', new ConnectSigner(decoded.data.pubkey, decoded.data.relays));
break;
case 'nsec':
c.set('signer', new NSecSigner(decoded.data));
break;
}
} catch {
console.debug('The user is not logged in');
}
}
await next();
};

View File

@ -1,18 +0,0 @@
import { AppMiddleware } from '@/app.ts';
import { UserStore } from '@/storages/UserStore.ts';
import { eventsDB } from '@/storages.ts';
/** Store middleware. */
const storeMiddleware: AppMiddleware = async (c, next) => {
const pubkey = c.get('pubkey') as string;
if (pubkey) {
const store = new UserStore(pubkey, eventsDB);
c.set('store', store);
} else {
c.set('store', eventsDB);
}
await next();
};
export { storeMiddleware };

View File

@ -0,0 +1,16 @@
import { AppMiddleware } from '@/app.ts';
import { UserStore } from '@/storages/UserStore.ts';
import { Storages } from '@/storages.ts';
/** Store middleware. */
export const storeMiddleware: AppMiddleware = async (c, next) => {
const pubkey = await c.get('signer')?.getPublicKey();
if (pubkey) {
const store = new UserStore(pubkey, await Storages.admin());
c.set('store', store);
} else {
c.set('store', await Storages.admin());
}
await next();
};

View File

@ -0,0 +1,35 @@
import { BlossomUploader, NostrBuildUploader } from '@nostrify/nostrify/uploaders';
import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DenoUploader } from '@/uploaders/DenoUploader.ts';
import { IPFSUploader } from '@/uploaders/IPFSUploader.ts';
import { S3Uploader } from '@/uploaders/S3Uploader.ts';
import { fetchWorker } from '@/workers/fetch.ts';
/** Set an uploader for the user. */
export const uploaderMiddleware: AppMiddleware = async (c, next) => {
const signer = c.get('signer');
switch (Conf.uploader) {
case 's3':
c.set('uploader', new S3Uploader(Conf.s3));
break;
case 'ipfs':
c.set('uploader', new IPFSUploader({ baseUrl: Conf.mediaDomain, apiUrl: Conf.ipfs.apiUrl, fetch: fetchWorker }));
break;
case 'local':
c.set('uploader', new DenoUploader({ baseUrl: Conf.mediaDomain, dir: Conf.uploadsDir }));
break;
case 'nostrbuild':
c.set('uploader', new NostrBuildUploader({ endpoint: Conf.nostrbuildEndpoint, signer, fetch: fetchWorker }));
break;
case 'blossom':
if (signer) {
c.set('uploader', new BlossomUploader({ servers: Conf.blossomServers, signer, fetch: fetchWorker }));
}
break;
}
await next();
};

4
src/nostr-wasm.ts Normal file
View File

@ -0,0 +1,4 @@
import { setNostrWasm } from 'nostr-tools/wasm';
import { initNostrWasm } from 'nostr-wasm';
await initNostrWasm().then(setNostrWasm);

View File

@ -1,37 +1,32 @@
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NKinds, NostrEvent, NPolicy, NSchema as n } from '@nostrify/nostrify';
import { LNURL } from '@nostrify/nostrify/ln'; import { LNURL } from '@nostrify/nostrify/ln';
import { PipePolicy } from '@nostrify/nostrify/policies';
import Debug from '@soapbox/stickynotes/debug';
import { sql } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { db } from '@/db.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { addRelays } from '@/db/relays.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { Debug, sql } from '@/deps.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { isEphemeralKind } from '@/kinds.ts';
import { DVM } from '@/pipeline/DVM.ts'; import { DVM } from '@/pipeline/DVM.ts';
import { RelayError } from '@/RelayError.ts';
import { updateStats } from '@/stats.ts'; import { updateStats } from '@/stats.ts';
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts';
import { cache, eventsDB, reqmeister, Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts'; import { getTagSet } from '@/tags.ts';
import { eventAge, isRelay, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; import { eventAge, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts'; import { fetchWorker } from '@/workers/fetch.ts';
import { policyWorker } from '@/workers/policy.ts';
import { TrendsWorker } from '@/workers/trends.ts'; import { TrendsWorker } from '@/workers/trends.ts';
import { verifyEventWorker } from '@/workers/verify.ts'; import { verifyEventWorker } from '@/workers/verify.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { lnurlCache } from '@/utils/lnurl.ts'; import { lnurlCache } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
const debug = Debug('ditto:pipeline'); const debug = Debug('ditto:pipeline');
let UserPolicy: any;
try {
UserPolicy = (await import('../data/policy.ts')).default;
debug('policy loaded from data/policy.ts');
} catch (_e) {
// do nothing
debug('policy not found');
}
/** /**
* Common pipeline function to process (and maybe store) events. * Common pipeline function to process (and maybe store) events.
* It is idempotent, so it can be called multiple times for the same event. * It is idempotent, so it can be called multiple times for the same event.
@ -40,45 +35,80 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
if (!(await verifyEventWorker(event))) return; if (!(await verifyEventWorker(event))) return;
if (await encounterEvent(event, signal)) return; if (await encounterEvent(event, signal)) return;
debug(`NostrEvent<${event.kind}> ${event.id}`); debug(`NostrEvent<${event.kind}> ${event.id}`);
await hydrateEvent(event, signal);
if (UserPolicy) { if (event.kind !== 24133) {
const result = await new UserPolicy().call(event, signal); await policyFilter(event);
debug(JSON.stringify(result));
const [_, _eventId, ok, reason] = result;
if (!ok) {
const [prefix, ...rest] = reason.split(': ');
throw new RelayError(prefix, rest.join(': '));
}
} }
await hydrateEvent(event, signal);
await Promise.all([ await Promise.all([
storeEvent(event, signal), storeEvent(event, signal),
parseMetadata(event, signal), parseMetadata(event, signal),
processDeletions(event, signal),
DVM.event(event), DVM.event(event),
trackRelays(event),
trackHashtags(event), trackHashtags(event),
fetchRelatedEvents(event, signal), fetchRelatedEvents(event),
processMedia(event), processMedia(event),
payZap(event, signal), payZap(event, signal),
streamOut(event), streamOut(event),
]); ]);
} }
async function policyFilter(event: NostrEvent): Promise<void> {
const debug = Debug('ditto:policy');
const policies: NPolicy[] = [
new MuteListPolicy(Conf.pubkey, await Storages.admin()),
];
try {
await policyWorker.import(Conf.policy);
policies.push(policyWorker);
debug(`Using custom policy: ${Conf.policy}`);
} catch (e) {
if (e.message.includes('Module not found')) {
debug('Custom policy not found <https://docs.soapbox.pub/ditto/policies/>');
} else {
console.error(`DITTO_POLICY (error importing policy): ${Conf.policy}`, e);
throw new RelayError('blocked', 'policy could not be loaded');
}
}
const policy = new PipePolicy(policies.reverse());
try {
const result = await policy.call(event);
debug(JSON.stringify(result));
RelayError.assert(result);
} catch (e) {
if (e instanceof RelayError) {
throw e;
} else {
console.error('POLICY ERROR:', e);
throw new RelayError('blocked', 'policy error');
}
}
}
/** Encounter the event, and return whether it has already been encountered. */ /** Encounter the event, and return whether it has already been encountered. */
async function encounterEvent(event: NostrEvent, signal: AbortSignal): Promise<boolean> { async function encounterEvent(event: NostrEvent, signal: AbortSignal): Promise<boolean> {
const cache = await Storages.cache();
const reqmeister = await Storages.reqmeister();
const [existing] = await cache.query([{ ids: [event.id], limit: 1 }]); const [existing] = await cache.query([{ ids: [event.id], limit: 1 }]);
cache.event(event); cache.event(event);
reqmeister.event(event, { signal }); reqmeister.event(event, { signal });
return !!existing; return !!existing;
} }
/** 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], storage: eventsDB, signal }); await hydrateEvents({ events: [event], store: await Storages.db(), signal });
const domain = await db const kysely = await DittoDB.getInstance();
const domain = await kysely
.selectFrom('pubkey_domains') .selectFrom('pubkey_domains')
.select('domain') .select('domain')
.where('pubkey', '=', event.pubkey) .where('pubkey', '=', event.pubkey)
@ -89,21 +119,11 @@ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<voi
/** Maybe store the event, if eligible. */ /** Maybe store the event, if eligible. */
async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<void> { async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<void> {
if (isEphemeralKind(event.kind)) return; if (NKinds.ephemeral(event.kind)) return;
const store = await Storages.db();
const [deletion] = await eventsDB.query( await updateStats(event).catch(debug);
[{ kinds: [5], authors: [Conf.pubkey, event.pubkey], '#e': [event.id], limit: 1 }], await store.event(event, { signal });
{ signal },
);
if (deletion) {
return Promise.reject(new RelayError('blocked', 'event was deleted'));
} else {
await Promise.all([
eventsDB.event(event, { signal }).catch(debug),
updateStats(event).catch(debug),
]);
}
} }
/** Parse kind 0 metadata and track indexes in the database. */ /** Parse kind 0 metadata and track indexes in the database. */
@ -111,7 +131,7 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
if (event.kind !== 0) return; if (event.kind !== 0) return;
// Parse metadata. // Parse metadata.
const metadata = n.json().pipe(n.metadata()).safeParse(event.content); const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
if (!metadata.success) return; if (!metadata.success) return;
// Get nip05. // Get nip05.
@ -128,6 +148,7 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
// Track pubkey domain. // Track pubkey domain.
try { try {
const kysely = await DittoDB.getInstance();
const { domain } = parseNip05(nip05); const { domain } = parseNip05(nip05);
await sql` await sql`
@ -137,31 +158,12 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
domain = excluded.domain, domain = excluded.domain,
last_updated_at = excluded.last_updated_at last_updated_at = excluded.last_updated_at
WHERE excluded.last_updated_at > pubkey_domains.last_updated_at WHERE excluded.last_updated_at > pubkey_domains.last_updated_at
`.execute(db); `.execute(kysely);
} catch (_e) { } catch (_e) {
// do nothing // do nothing
} }
} }
/** Query to-be-deleted events, ensure their pubkey matches, then delete them from the database. */
async function processDeletions(event: NostrEvent, signal: AbortSignal): Promise<void> {
if (event.kind === 5) {
const ids = getTagSet(event.tags, 'e');
if (event.pubkey === Conf.pubkey) {
await eventsDB.remove([{ ids: [...ids] }], { signal });
} else {
const events = await eventsDB.query(
[{ ids: [...ids], authors: [event.pubkey] }],
{ signal },
);
const deleteIds = events.map(({ id }) => id);
await eventsDB.remove([{ ids: deleteIds }], { signal });
}
}
}
/** Track whenever a hashtag is used, for processing trending tags. */ /** Track whenever a hashtag is used, for processing trending tags. */
async function trackHashtags(event: NostrEvent): Promise<void> { async function trackHashtags(event: NostrEvent): Promise<void> {
const date = nostrDate(event.created_at); const date = nostrDate(event.created_at);
@ -181,33 +183,26 @@ async function trackHashtags(event: NostrEvent): Promise<void> {
} }
} }
/** Tracks known relays in the database. */
function trackRelays(event: NostrEvent) {
const relays = new Set<`wss://${string}`>();
event.tags.forEach((tag) => {
if (['p', 'e', 'a'].includes(tag[0]) && isRelay(tag[2])) {
relays.add(tag[2]);
}
if (event.kind === 10002 && tag[0] === 'r' && isRelay(tag[1])) {
relays.add(tag[1]);
}
});
return addRelays([...relays]);
}
/** Queue related events to fetch. */ /** Queue related events to fetch. */
async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) { async function fetchRelatedEvents(event: DittoEvent) {
if (!event.user) { const cache = await Storages.cache();
reqmeister.req({ kinds: [0], authors: [event.pubkey] }, { signal }).catch(() => {}); const reqmeister = await Storages.reqmeister();
if (!event.author) {
const signal = AbortSignal.timeout(3000);
reqmeister.query([{ kinds: [0], authors: [event.pubkey] }], { signal })
.then((events) => Promise.allSettled(events.map((event) => handleEvent(event, signal))))
.catch(() => {});
} }
for (const [name, id, relay] of event.tags) { for (const [name, id] of event.tags) {
if (name === 'e') { if (name === 'e') {
const { count } = await cache.count([{ ids: [id] }]); const { count } = await cache.count([{ ids: [id] }]);
if (!count) { if (!count) {
reqmeister.req({ ids: [id] }, { relays: [relay] }).catch(() => {}); const signal = AbortSignal.timeout(3000);
reqmeister.query([{ ids: [id] }], { signal })
.then((events) => Promise.allSettled(events.map((event) => handleEvent(event, signal))))
.catch(() => {});
} }
} }
} }
@ -276,15 +271,9 @@ function isFresh(event: NostrEvent): boolean {
/** Distribute the event through active subscriptions. */ /** Distribute the event through active subscriptions. */
async function streamOut(event: NostrEvent): Promise<void> { async function streamOut(event: NostrEvent): Promise<void> {
if (isFresh(event)) { if (isFresh(event)) {
await Storages.pubsub.event(event); const pubsub = await Storages.pubsub();
await pubsub.event(event);
} }
} }
/** NIP-20 command line result. */ export { handleEvent };
class RelayError extends Error {
constructor(prefix: 'duplicate' | 'pow' | 'blocked' | 'rate-limited' | 'invalid' | 'error', message: string) {
super(`${prefix}: ${message}`);
}
}
export { handleEvent, RelayError };

View File

@ -3,7 +3,7 @@ import { NIP05, NostrEvent } from '@nostrify/nostrify';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import * as pipeline from '@/pipeline.ts'; import * as pipeline from '@/pipeline.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts'; import { Storages } from '@/storages.ts';
export class DVM { export class DVM {
static async event(event: NostrEvent): Promise<void> { static async event(event: NostrEvent): Promise<void> {
@ -34,7 +34,9 @@ export class DVM {
return DVM.feedback(event, 'error', `Forbidden user: ${user}`); return DVM.feedback(event, 'error', `Forbidden user: ${user}`);
} }
const [label] = await eventsDB.query([{ const store = await Storages.db();
const [label] = await store.query([{
kinds: [1985], kinds: [1985],
authors: [admin], authors: [admin],
'#L': ['nip05'], '#L': ['nip05'],

View File

@ -0,0 +1,72 @@
import { MockRelay } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert';
import { UserStore } from '@/storages/UserStore.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import userBlack from '~/fixtures/events/kind-0-black.json' with { type: 'json' };
import userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' };
import blockEvent from '~/fixtures/events/kind-10000-black-blocks-user-me.json' with { type: 'json' };
import event1authorUserMe from '~/fixtures/events/event-1-quote-repost-will-be-reposted.json' with { type: 'json' };
import event1 from '~/fixtures/events/event-1.json' with { type: 'json' };
Deno.test('block event: muted user cannot post', async () => {
const userBlackCopy = structuredClone(userBlack);
const userMeCopy = structuredClone(userMe);
const blockEventCopy = structuredClone(blockEvent);
const event1authorUserMeCopy = structuredClone(event1authorUserMe);
const db = new MockRelay();
const store = new UserStore(userBlackCopy.pubkey, db);
const policy = new MuteListPolicy(userBlack.pubkey, db);
await store.event(blockEventCopy);
await store.event(userBlackCopy);
await store.event(userMeCopy);
const ok = await policy.call(event1authorUserMeCopy);
assertEquals(ok, ['OK', event1authorUserMeCopy.id, false, 'blocked: Your account has been deactivated.']);
});
Deno.test('allow event: user is NOT muted because there is no muted event', async () => {
const userBlackCopy = structuredClone(userBlack);
const userMeCopy = structuredClone(userMe);
const event1authorUserMeCopy = structuredClone(event1authorUserMe);
const db = new MockRelay();
const store = new UserStore(userBlackCopy.pubkey, db);
const policy = new MuteListPolicy(userBlack.pubkey, db);
await store.event(userBlackCopy);
await store.event(userMeCopy);
const ok = await policy.call(event1authorUserMeCopy);
assertEquals(ok, ['OK', event1authorUserMeCopy.id, true, '']);
});
Deno.test('allow event: user is NOT muted because he is not in mute event', async () => {
const userBlackCopy = structuredClone(userBlack);
const userMeCopy = structuredClone(userMe);
const event1authorUserMeCopy = structuredClone(event1authorUserMe);
const blockEventCopy = structuredClone(blockEvent);
const event1copy = structuredClone(event1);
const db = new MockRelay();
const store = new UserStore(userBlackCopy.pubkey, db);
const policy = new MuteListPolicy(userBlack.pubkey, db);
await store.event(userBlackCopy);
await store.event(blockEventCopy);
await store.event(userMeCopy);
await store.event(event1copy);
await store.event(event1authorUserMeCopy);
const ok = await policy.call(event1copy);
assertEquals(ok, ['OK', event1.id, true, '']);
});

View File

@ -0,0 +1,18 @@
import { NostrEvent, NostrRelayOK, NPolicy, NStore } from '@nostrify/nostrify';
import { getTagSet } from '@/tags.ts';
export class MuteListPolicy implements NPolicy {
constructor(private pubkey: string, private store: NStore) {}
async call(event: NostrEvent): Promise<NostrRelayOK> {
const [muteList] = await this.store.query([{ authors: [this.pubkey], kinds: [10000], limit: 1 }]);
const pubkeys = getTagSet(muteList?.tags ?? [], 'p');
if (pubkeys.has(event.pubkey)) {
return ['OK', event.id, false, 'blocked: Your account has been deactivated.'];
}
return ['OK', event.id, true, ''];
}
}

Some files were not shown because too many files have changed in this diff Show More