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

2
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
image: denoland/deno:1.41.3
image: denoland/deno:1.43.3
default:
interruptible: true
@ -23,3 +23,9 @@ test:
script: deno task test
variables:
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.
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">
⚠️ 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] Share posts
- [x] Reposts
- [ ] Notifications
- [x] Notifications
- [x] Profiles
- [ ] Search
- [ ] Moderation
- [x] Moderation
- [ ] Zaps
- [x] Customizable
- [x] Open source
- [x] Self-hosted

View File

@ -4,27 +4,57 @@
"tasks": {
"start": "deno run -A 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",
"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",
"relays:sync": "deno run -A scripts/relays.ts sync",
"nsec": "deno run scripts/nsec.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"],
"imports": {
"@/": "./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/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/media-types": "jsr:@std/media-types@^0.224.1",
"@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/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",
"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/"
},
"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:
- `d` - pubkey of the user.
- `name` - NIP-05 username granted to the user, without the domain.
- `role` - one of `admin` or `user`.
- `origin` - the origin of the user's NIP-05, at the time the event was published.
Example:
@ -25,7 +23,6 @@ Example:
"tags": [
["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],
["role", "user"],
["origin", "https://ditto.ngrok.app"],
["alt", "User's account was updated by the admins of ditto.ngrok.app"]
],
"sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507"

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

View File

@ -1,15 +1,16 @@
import { JsonParseStream } from '@std/json/json-parse-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 { EventsDB } from '@/storages/events-db.ts';
import { EventsDB } from '@/storages/EventsDB.ts';
import { type EventStub } from '@/utils/api.ts';
import { nostrNow } from '@/utils.ts';
const signer = new AdminSigner();
const eventsDB = new EventsDB(db);
const kysely = await DittoDB.getInstance();
const eventsDB = new EventsDB(kysely);
const readable = Deno.stdin.readable
.pipeThrough(new TextDecoderStream())

View File

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

View File

@ -1,7 +1,8 @@
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` */
await dotenv.load({
@ -41,7 +42,7 @@ class Conf {
}
static get port() {
return parseInt(Deno.env.get('PORT') || '8000');
return parseInt(Deno.env.get('PORT') || '4036');
}
static get relay(): `wss://${string}` | `ws://${string}` {
@ -54,7 +55,7 @@ class Conf {
}
/** Origin of the Ditto server, including the protocol and port. */
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. */
static get externalDomain() {
@ -135,10 +136,22 @@ class Conf {
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. */
static get 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. */
static get mediaDomain() {
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

View File

@ -9,7 +9,7 @@ const actorController: AppController = async (c) => {
const username = c.req.param('username');
const { signal } = c.req.raw;
const pointer = await localNip05Lookup(username);
const pointer = await localNip05Lookup(c.get('store'), username);
if (!pointer) return notFound(c);
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 { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { nip19 } from '@/deps.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { eventsDB, searchStore } from '@/storages.ts';
import { Storages } from '@/storages.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 { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.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 { renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { bech32ToPubkey } from '@/utils.ts';
const usernameSchema = z
.string().min(1).max(30)
@ -30,7 +29,7 @@ const createAccountSchema = z.object({
});
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());
if (!result.success) {
@ -46,28 +45,32 @@ const createAccountController: 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'] });
if (event) {
const account = await renderAccount(event, { withSource: true });
const eventsDB = await Storages.db();
const [userPreferencesEvent] = await eventsDB.query([{
const [author, [settingsStore]] = await Promise.all([
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
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;
}]),
]);
const account = author
? await renderAccount(author, { 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);
} else {
return c.json(await accountFromPubkey(pubkey, { withSource: true }));
}
};
const accountController: AppController = async (c) => {
@ -92,28 +95,44 @@ const accountLookupController: AppController = async (c) => {
if (event) {
return c.json(await renderAccount(event));
}
try {
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 q = c.req.query('q');
const accountSearchQuerySchema = z.object({
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) {
return c.json({ error: 'Missing `q` query parameter.' }, 422);
const accountSearchController: AppController = async (c) => {
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 store = await Storages.search();
const [event, events] = await Promise.all([
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({
events: event ? [event, ...events] : events,
storage: eventsDB,
signal: c.req.raw.signal,
store,
signal,
});
if ((results.length < 1) && query.match(/npub1\w+/)) {
@ -132,7 +151,7 @@ const accountSearchController: 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[]'));
if (!ids.success) {
@ -157,8 +176,10 @@ const accountStatusesController: AppController = async (c) => {
const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw;
const store = await Storages.db();
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) {
const pinnedEventIds = getTagSet(pinEvent.tags, 'e');
return renderStatuses(c, [...pinnedEventIds].reverse());
@ -179,8 +200,8 @@ const accountStatusesController: AppController = async (c) => {
filter['#t'] = [tagged];
}
const events = await eventsDB.query([filter], { signal })
.then((events) => hydrateEvents({ events, storage: eventsDB, signal }))
const events = await store.query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal }))
.then((events) => {
if (exclude_replies) {
return events.filter((event) => !findReplyTag(event.tags));
@ -188,7 +209,11 @@ const accountStatusesController: AppController = async (c) => {
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);
};
@ -201,11 +226,12 @@ const updateCredentialsSchema = z.object({
bot: z.boolean().optional(),
discoverable: z.boolean().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 pubkey = c.get('pubkey')!;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const body = await parseBody(c.req.raw);
const result = updateCredentialsSchema.safeParse(body);
@ -214,7 +240,7 @@ const updateCredentialsController: AppController = async (c) => {
}
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 {
avatar: avatarFile,
@ -225,8 +251,8 @@ const updateCredentialsController: AppController = async (c) => {
} = result.data;
const [avatar, header] = await Promise.all([
avatarFile ? uploadFile(avatarFile, { pubkey }) : undefined,
headerFile ? uploadFile(headerFile, { pubkey }) : undefined,
avatarFile ? uploadFile(c, avatarFile, { pubkey }) : undefined,
headerFile ? uploadFile(c, headerFile, { pubkey }) : undefined,
]);
meta.name = display_name ?? meta.name;
@ -241,55 +267,46 @@ const updateCredentialsController: AppController = async (c) => {
tags: [],
}, c);
const pleroma_frontend = result.data.pleroma_settings_store;
if (pleroma_frontend) {
const signer = new APISigner(c);
const account = await renderAccount(event, { withSource: true });
const settingsStore = result.data.pleroma_settings_store;
if (settingsStore) {
await createEvent({
kind: 30078,
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);
}
const account = await renderAccount(event, { withSource: true });
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;
}
account.pleroma.settings_store = settingsStore;
return c.json(account);
};
/** https://docs.joinmastodon.org/methods/accounts/#follow */
const followController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [3], authors: [sourcePubkey] },
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]),
c,
);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
relationship.following = true;
return c.json(relationship);
};
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */
const unfollowController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [3], authors: [sourcePubkey] },
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]),
c,
);
@ -311,12 +328,22 @@ const followingController: AppController = async (c) => {
};
/** https://docs.joinmastodon.org/methods/accounts/#block */
const blockController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const blockController: AppController = (c) => {
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');
await updateListEvent(
{ kinds: [10000], authors: [sourcePubkey] },
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]),
c,
);
@ -325,13 +352,13 @@ const blockController: AppController = async (c) => {
return c.json(relationship);
};
/** https://docs.joinmastodon.org/methods/accounts/#unblock */
const unblockController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
/** https://docs.joinmastodon.org/methods/accounts/#unmute */
const unmuteController: AppController = async (c) => {
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [10000], authors: [sourcePubkey] },
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]),
c,
);
@ -341,11 +368,13 @@ const unblockController: 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 { signal } = c.req.raw;
const events7 = await eventsDB.query(
const store = await Storages.db();
const events7 = await store.query(
[{ kinds: [7], authors: [pubkey], ...params }],
{ signal },
);
@ -354,10 +383,14 @@ const favouritesController: AppController = async (c) => {
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
.filter((id): id is string => !!id);
const events1 = await eventsDB.query([{ kinds: [1], ids }], { signal })
.then((events) => hydrateEvents({ events, storage: eventsDB, signal }));
const events1 = await store.query([{ kinds: [1], ids }], { 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);
};
@ -372,9 +405,11 @@ export {
followController,
followersController,
followingController,
muteController,
relationshipsController,
unblockController,
unfollowController,
unmuteController,
updateCredentialsController,
verifyCredentialsController,
};

View File

@ -2,10 +2,12 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.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 { paginated, paginationSchema } from '@/utils/api.ts';
const adminAccountQuerySchema = z.object({
local: booleanParamSchema.optional(),
@ -38,16 +40,17 @@ const adminAccountsController: AppController = async (c) => {
return c.json([]);
}
const store = await Storages.db();
const { since, until, limit } = paginationSchema.parse(c.req.query());
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 authors = await eventsDB.query([{ kinds: [0], authors: pubkeys }], { signal });
const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
for (const event of events) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
event.d_author = authors.find((author) => author.pubkey === d);
(event as DittoEvent).d_author = authors.find((author) => author.pubkey === d);
}
const accounts = await Promise.all(
@ -57,4 +60,32 @@ const adminAccountsController: AppController = async (c) => {
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 { eventsDB } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderAccounts } from '@/views.ts';
import { AppController } from '@/app.ts';
/** https://docs.joinmastodon.org/methods/blocks/#get */
const blocksController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
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 const blocksController: AppController = (c) => {
return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
};
export { blocksController };

View File

@ -1,14 +1,15 @@
import { type AppController } from '@/app.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderStatuses } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/bookmarks/#get */
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 [event10003] = await eventsDB.query(
const [event10003] = await store.query(
[{ kinds: [10003], authors: [pubkey], limit: 1 }],
{ signal },
);

View File

@ -3,19 +3,22 @@ import { z } from 'zod';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
const markerSchema = z.enum(['read', 'write']);
const relaySchema = z.object({
url: z.string().url(),
read: z.boolean(),
write: z.boolean(),
marker: markerSchema.optional(),
});
type RelayEntity = z.infer<typeof relaySchema>;
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 },
]);
@ -27,16 +30,17 @@ export const adminRelaysController: AppController = async (c) => {
};
export const adminSetRelaysController: AppController = async (c) => {
const store = await Storages.db();
const relays = relaySchema.array().parse(await c.req.json());
const event = await new AdminSigner().signEvent({
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: '',
created_at: Math.floor(Date.now() / 1000),
});
await eventsDB.event(event);
await store.event(event);
return c.json(renderRelays(event));
};
@ -47,8 +51,7 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
if (name === 'r') {
const relay: RelayEntity = {
url,
read: !marker || marker === 'read',
write: !marker || marker === 'write',
marker: markerSchema.safeParse(marker).success ? marker as 'read' | 'write' : undefined,
};
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 { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts';
const instanceController: AppController = async (c) => {
const { host, protocol } = Conf.url;
const { signal } = c.req.raw;
const [event] = await eventsDB.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal });
const meta = jsonServerMetaSchema.parse(event?.content);
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
return c.json({
uri: host,
title: meta.name ?? 'Ditto',
description: meta.about ?? 'Nostr and the Fediverse',
short_description: meta.tagline ?? meta.about ?? 'Nostr and the Fediverse',
title: meta.name,
description: meta.about,
short_description: meta.tagline,
registrations: true,
max_toot_chars: Conf.postCharLimit,
configuration: {
@ -43,6 +40,7 @@ const instanceController: AppController = async (c) => {
'mastodon_api_streaming',
'exposable_reactions',
'quote_posting',
'v2_suggestions',
],
},
},
@ -56,7 +54,7 @@ const instanceController: AppController = async (c) => {
streaming_api: `${wsProtocol}//${host}`,
},
version: '0.0.0 (compatible; Ditto 0.0.1)',
email: meta.email ?? `postmaster@${host}`,
email: meta.email,
nostr: {
pubkey: Conf.pubkey,
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 { parseBody } from '@/utils/api.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { uploadFile } from '@/upload.ts';
import { uploadFile } from '@/utils/upload.ts';
const mediaBodySchema = z.object({
file: fileSchema,
@ -14,7 +14,7 @@ const mediaBodySchema = z.object({
});
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 { signal } = c.req.raw;
@ -24,7 +24,7 @@ const mediaController: AppController = async (c) => {
try {
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));
} catch (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 { eventsDB } from '@/storages.ts';
import { NostrFilter } from '@nostrify/nostrify';
import { AppContext, AppController } from '@/app.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginationSchema } from '@/utils/api.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts';
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 { signal } = c.req.raw;
const events = await eventsDB.query(
[{ kinds: [1], '#p': [pubkey], since, until }],
{ signal },
);
const statuses = await Promise.all(events.map((event) => renderNotification(event, pubkey)));
return paginated(c, events, statuses);
return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]);
};
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 };

View File

@ -1,9 +1,12 @@
import { encodeBase64 } from '@std/encoding/base64';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { lodash, nip19 } from '@/deps.ts';
import { AppController } from '@/app.ts';
import { lodash } from '@/deps.ts';
import { nostrNow } from '@/utils.ts';
import { parseBody } from '@/utils/api.ts';
import { getClientConnectUri } from '@/utils/connect.ts';
const passwordGrantSchema = z.object({
grant_type: z.literal('password'),
@ -59,25 +62,16 @@ const createTokenController: AppController = async (c) => {
};
/** Display the OAuth form. */
const oauthController: AppController = (c) => {
const oauthController: AppController = async (c) => {
const encodedUri = c.req.query('redirect_uri');
if (!encodedUri) {
return c.text('Missing `redirect_uri` query param.', 422);
}
const redirectUri = maybeDecodeUri(encodedUri);
const connectUri = await getClientConnectUri(c.req.raw.signal);
c.res.headers.set(
'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>
const script = `
window.addEventListener('load', function() {
if ('nostr' in window) {
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>
<body>
<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)}">
<button type="submit">Authorize</button>
</form>
<br>
<a href="${lodash.escape(connectUri)}">Nostr Connect</a>
</body>
</html>
`);

View File

@ -1,15 +1,16 @@
import { NSchema as n, NStore } from '@nostrify/nostrify';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { createAdminEvent } from '@/utils/api.ts';
import { jsonSchema } from '@/schema.ts';
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');
if (frontendConfig) {
@ -25,7 +26,8 @@ const frontendConfigController: 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 });
};
@ -33,7 +35,8 @@ const configController: AppController = async (c) => {
const updateConfigController: AppController = async (c) => {
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());
for (const { group, key, value } of newConfigs) {
@ -63,10 +66,10 @@ const pleromaAdminDeleteStatusController: AppController = async (c) => {
return c.json({});
};
async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> {
async function getConfigs(store: NStore, signal: AbortSignal): Promise<PleromaConfig[]> {
const { pubkey } = Conf;
const [event] = await eventsDB.query([{
const [event] = await store.query([{
kinds: [30078],
authors: [pubkey],
'#d': ['pub.ditto.pleroma.config'],
@ -75,7 +78,7 @@ async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> {
try {
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) {
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 { AppController } from '@/app.ts';
import { nip19 } from '@/deps.ts';
import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { searchStore } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
@ -20,7 +19,7 @@ const searchQuerySchema = z.object({
type: z.enum(['accounts', 'statuses', 'hashtags']).optional(),
resolve: booleanParamSchema.optional().transform(Boolean),
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)),
});
@ -44,6 +43,7 @@ const searchController: AppController = async (c) => {
}
const results = dedupeEvents(events);
const viewerPubkey = await c.get('signer')?.getPublicKey();
const [accounts, statuses] = await Promise.all([
Promise.all(
@ -55,7 +55,7 @@ const searchController: AppController = async (c) => {
Promise.all(
results
.filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))
.map((event) => renderStatus(event, { viewerPubkey }))
.filter(Boolean),
),
]);
@ -78,7 +78,7 @@ const searchController: AppController = async (c) => {
};
/** 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([]);
const filter: NostrFilter = {
@ -91,8 +91,10 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: Abort
filter.authors = [account_id];
}
return searchStore.query([filter], { signal })
.then((events) => hydrateEvents({ events, storage: searchStore, signal }));
const store = await Storages.search();
return store.query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal }));
}
/** 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. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
const filters = await getLookupFilters(query, signal);
const store = await Storages.search();
return searchStore.query(filters, { limit: 1, signal })
.then((events) => hydrateEvents({ events, storage: searchStore, signal }))
return store.query(filters, { limit: 1, signal })
.then((events) => hydrateEvents({ events, store, signal }))
.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 { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { ISO6391, nip19 } from '@/deps.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { addTag, deleteTag } from '@/tags.ts';
import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
const createStatusSchema = z.object({
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
@ -31,6 +32,7 @@ const createStatusSchema = z.object({
sensitive: z.boolean().nullish(),
spoiler_text: z.string().nullish(),
status: z.string().nullish(),
to: z.string().array().nullish(),
visibility: z.enum(['public', 'unlisted', 'private', 'direct']).nullish(),
quote_id: z.string().nullish(),
}).refine(
@ -47,7 +49,7 @@ const statusController: AppController = async (c) => {
});
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);
@ -56,6 +58,7 @@ const statusController: AppController = async (c) => {
const createStatusController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body);
const kysely = await DittoDB.getInstance();
if (!result.success) {
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]);
}
if (data.media_ids?.length) {
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]));
const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
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 pubkey = await lookupPubkey(username);
if (!pubkey) return match;
// Content addressing (default)
if (!data.to) {
pubkeys.add(pubkey);
}
try {
const result = nip19.decode(username);
if (result.type === 'npub') {
tags.push(['p', result.data]);
return `nostr:${username}`;
} else {
return `nostr:${nip19.npubEncode(pubkey)}`;
} catch {
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)) {
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({
kind: 1,
content,
content: content + mediaCompat,
tags,
}, c);
@ -136,17 +152,17 @@ const createStatusController: AppController = async (c) => {
if (data.quote_id) {
await hydrateEvents({
events: [event],
storage: eventsDB,
store: await Storages.db(),
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 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 });
@ -170,9 +186,12 @@ const deleteStatusController: AppController = async (c) => {
const contextController: AppController = async (c) => {
const id = c.req.param('id');
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[]) {
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);
}
@ -202,7 +221,7 @@ const favouriteController: AppController = async (c) => {
],
}, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') });
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
if (status) {
status.favourited = true;
@ -241,11 +260,11 @@ const reblogStatusController: AppController = async (c) => {
await hydrateEvents({
events: [reblogEvent],
storage: eventsDB,
store: await Storages.db(),
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);
};
@ -253,23 +272,28 @@ const reblogStatusController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */
const unreblogStatusController: AppController = async (c) => {
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, {
kind: 1,
});
if (!event) return c.json({ error: 'Event not found.' }, 404);
const [event] = await store.query([{ ids: [eventId], kinds: [1] }]);
if (!event) {
return c.json({ error: 'Record not found' }, 404);
}
const filters: NostrFilter[] = [{ kinds: [6], authors: [pubkey], '#e': [event.id] }];
const [repostedEvent] = await eventsDB.query(filters, { limit: 1 });
if (!repostedEvent) return c.json({ error: 'Event not found.' }, 404);
const [repostedEvent] = await store.query(
[{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
);
if (!repostedEvent) {
return c.json({ error: 'Record not found' }, 404);
}
await createEvent({
kind: 5,
tags: [['e', repostedEvent.id]],
}, c);
return c.json(await renderStatus(event, {}));
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
};
const rebloggedByController: AppController = (c) => {
@ -280,7 +304,7 @@ const rebloggedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@ -290,7 +314,7 @@ const bookmarkController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10003], authors: [pubkey] },
{ kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', eventId]),
c,
);
@ -307,7 +331,7 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@ -317,7 +341,7 @@ const unbookmarkController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10003], authors: [pubkey] },
{ kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', eventId]),
c,
);
@ -334,7 +358,7 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@ -344,7 +368,7 @@ const pinController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10001], authors: [pubkey] },
{ kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', eventId]),
c,
);
@ -361,7 +385,7 @@ const pinController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unpin */
const unpinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const { signal } = c.req.raw;
@ -373,7 +397,7 @@ const unpinController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10001], authors: [pubkey] },
{ kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', eventId]),
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 author = target?.author;
const meta = jsonMetaContentSchema.parse(author?.content);
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta);
if (target && lnurl) {
@ -421,7 +445,7 @@ const zapController: AppController = async (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;
return c.json(status);

View File

@ -1,15 +1,15 @@
import { NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Debug } from '@/deps.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import { getFeedPubkeys } from '@/queries.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { bech32ToPubkey } from '@/utils.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');
@ -69,11 +69,24 @@ const streamingController: AppController = (c) => {
if (!filter) return;
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') {
const [event] = await hydrateEvents({
events: [msg[2]],
storage: eventsDB,
const event = msg[2];
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),
});

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

View File

@ -1,18 +1,17 @@
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts';
const relayInfoController: AppController = async (c) => {
const { signal } = c.req.raw;
const [event] = await eventsDB.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal });
const meta = jsonServerMetaSchema.parse(event?.content);
const store = await Storages.db();
const meta = await getInstanceMetadata(store, c.req.raw.signal);
return c.json({
name: meta.name ?? 'Ditto',
description: meta.about ?? 'Nostr and the Fediverse.',
name: meta.name,
description: meta.about,
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],
software: 'Ditto',
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 {
type ClientCLOSE,
type ClientCOUNT,
type ClientEVENT,
type ClientMsg,
clientMsgSchema,
type ClientREQ,
} from '@/schemas/nostr.ts';
NostrClientCLOSE,
NostrClientCOUNT,
NostrClientEVENT,
NostrClientMsg,
NostrClientREQ,
NostrEvent,
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 type { AppController } from '@/app.ts';
@ -31,7 +32,7 @@ function connectStream(socket: WebSocket) {
const controllers = new Map<string, AbortController>();
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) {
handleMsg(result.data);
} else {
@ -46,7 +47,7 @@ function connectStream(socket: WebSocket) {
};
/** Handle client message. */
function handleMsg(msg: ClientMsg) {
function handleMsg(msg: NostrClientMsg) {
switch (msg[0]) {
case 'REQ':
handleReq(msg);
@ -64,21 +65,24 @@ function connectStream(socket: WebSocket) {
}
/** 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 controller = new AbortController();
controllers.get(subId)?.abort();
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(['EOSE', subId]);
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') {
send(['EVENT', subId, msg[2]]);
}
@ -89,13 +93,13 @@ function connectStream(socket: WebSocket) {
}
/** Handle EVENT. Store the event. */
async function handleEvent([_, event]: ClientEVENT): Promise<void> {
async function handleEvent([_, event]: NostrClientEVENT): Promise<void> {
try {
// This will store it (if eligible) and run other side-effects.
await pipeline.handleEvent(event, AbortSignal.timeout(1000));
send(['OK', event.id, true, '']);
} catch (e) {
if (e instanceof pipeline.RelayError) {
if (e instanceof RelayError) {
send(['OK', event.id, false, e.message]);
} else {
send(['OK', event.id, false, 'error: something went wrong']);
@ -105,7 +109,7 @@ function connectStream(socket: WebSocket) {
}
/** Handle CLOSE. Close the subscription. */
function handleClose([_, subId]: ClientCLOSE): void {
function handleClose([_, subId]: NostrClientCLOSE): void {
const controller = controllers.get(subId);
if (controller) {
controller.abort();
@ -114,8 +118,9 @@ function connectStream(socket: WebSocket) {
}
/** Handle COUNT. Return the number of events matching the filters. */
async function handleCount([_, subId, ...rest]: ClientCOUNT): Promise<void> {
const { count } = await eventsDB.count(prepareFilters(rest));
async function handleCount([_, subId, ...rest]: NostrClientCOUNT): Promise<void> {
const store = await Storages.db();
const { count } = await store.count(prepareFilters(rest));
send(['COUNT', subId, { count, approximate: false }]);
}
@ -128,7 +133,7 @@ function connectStream(socket: WebSocket) {
}
/** Enforce the filters with certain criteria. */
function prepareFilters(filters: ClientREQ[2][]): NostrFilter[] {
function prepareFilters(filters: NostrClientREQ[2][]): NostrFilter[] {
return filters.map((filter) => {
const narrow = Boolean(filter.ids?.length || filter.authors?.length);
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 result = nameSchema.safeParse(c.req.query('name'));
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) {
return c.json({ names: {}, relays: {} });

View File

@ -1,7 +1,7 @@
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { Conf } from '@/config.ts';
import { nip19 } from '@/deps.ts';
import { localNip05Lookup } from '@/utils/nip05.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 pointer = await localNip05Lookup(username);
const pointer = await localNip05Lookup(c.get('store'), username);
if (!pointer) {
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 { DittoPostgres } from '@/db/adapters/DittoPostgres.ts';
import { DittoSQLite } from '@/db/adapters/DittoSQLite.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { Kysely } from '@/deps.ts';
export class DittoDB {
private static kysely: Promise<Kysely<DittoTables>> | undefined;
static getInstance(): Promise<Kysely<DittoTables>> {
if (!this.kysely) {
this.kysely = this._getInstance();
}
return this.kysely;
}
static async _getInstance(): Promise<Kysely<DittoTables>> {
const { databaseUrl } = Conf;
let kysely: Kysely<DittoTables>;
switch (databaseUrl.protocol) {
case 'sqlite:':
return DittoSQLite.getInstance();
kysely = await DittoSQLite.getInstance();
break;
case 'postgres:':
case 'postgresql:':
return DittoPostgres.getInstance();
kysely = await DittoPostgres.getInstance();
break;
default:
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 {
events: EventRow;
events_fts: EventFTSRow;
tags: TagRow;
relays: RelayRow;
nostr_events: EventRow;
nostr_tags: TagRow;
nostr_fts5: EventFTSRow;
unattached_media: UnattachedMediaRow;
author_stats: AuthorStatsRow;
event_stats: EventStatsRow;
@ -31,24 +30,17 @@ interface EventRow {
created_at: number;
tags: string;
sig: string;
deleted_at: number | null;
}
interface EventFTSRow {
id: string;
event_id: string;
content: string;
}
interface TagRow {
tag: string;
value: string;
event_id: string;
}
interface RelayRow {
url: string;
domain: string;
active: boolean;
name: string;
value: string;
}
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 { PostgreSQLDriver } from 'kysely_deno_postgres';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts';
export class DittoPostgres {
static db: Kysely<DittoTables> | undefined;
@ -16,9 +18,10 @@ export class DittoPostgres {
},
// @ts-ignore mismatched kysely versions probably
createDriver() {
return new PostgreSQLDriver({
connectionString: Deno.env.get('DATABASE_URL'),
});
return new PostgreSQLDriver(
{ connectionString: Deno.env.get('DATABASE_URL') },
Conf.pg.poolSize,
);
},
createIntrospector(db: Kysely<unknown>) {
return new PostgresIntrospector(db);
@ -27,6 +30,7 @@ export class DittoPostgres {
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 { DittoTables } from '@/db/DittoTables.ts';
import { Kysely, PolySqliteDialect, sql } from '@/deps.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts';
import SqliteWorker from '@/workers/sqlite.ts';
export class DittoSQLite {
@ -15,6 +18,7 @@ export class DittoSQLite {
dialect: new PolySqliteDialect({
database: sqliteWorker,
}),
log: KyselyLogger,
});
// 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> {
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> {
await db.schema

View File

@ -1,5 +1,6 @@
import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts';
import { Kysely, sql } from '@/deps.ts';
export async function up(db: Kysely<any>): Promise<void> {
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> {
}

View File

@ -1,4 +1,4 @@
import { Kysely } from '@/deps.ts';
import { Kysely } from 'kysely';
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> {
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> {
}

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { Conf } from '@/config.ts';
import { Debug } from '@/deps.ts';
import * as pipeline from '@/pipeline.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
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) {
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';
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';
import 'deno-safe-fetch';
// @deno-types="npm:@types/lodash@4.14.194"
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"
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 {
type ParsedSignature,
pemToPublicKey,
@ -35,44 +11,6 @@ export {
verifyRequest,
} 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 * as secp from 'npm:@noble/secp256k1@^2.0.0';
export { LRUCache } from 'npm:lru-cache@^10.2.0';
export {
DB as Sqlite,
SqliteError,
} 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 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 { stringifyStable } from '@/deps.ts';
import { isReplaceableKind } from '@/kinds.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Microfilter to get one specific event by ID. */
type IdMicrofilter = { ids: [NostrEvent['id']] };
/** Microfilter to get an author. */
@ -42,8 +40,8 @@ function getMicroFilters(event: NostrEvent): MicroFilter[] {
/** Microfilter schema. */
const microFilterSchema = z.union([
z.object({ ids: z.tuple([nostrIdSchema]) }).strict(),
z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([nostrIdSchema]) }).strict(),
z.object({ ids: z.tuple([n.id()]) }).strict(),
z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([n.id()]) }).strict(),
]);
/** Checks whether the filter is a microfilter. */
@ -51,22 +49,6 @@ function isMicrofilter(filter: NostrFilter): filter is MicroFilter {
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. */
function canFilter(filter: NostrFilter): boolean {
return getFilterLimit(filter) > 0;

View File

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

View File

@ -23,5 +23,15 @@ export interface DittoEvent extends NostrEvent {
d_author?: DittoEvent;
user?: 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 { HTTPException } from 'hono';
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 {
buildAuthEventTemplate,
parseAuthRequest,
type ParseAuthRequestOpts,
validateAuthEvent,
} 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.
* https://github.com/nostr-protocol/nips/blob/master/98.md
*/
function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware {
function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware {
return async (c, next) => {
const req = localRequest(c);
const result = await parseAuthRequest(req, opts);
if (result.success) {
c.set('pubkey', result.data.pubkey);
c.set('signer', new ConnectSigner(result.data.pubkey));
c.set('proof', result.data);
}
@ -33,9 +34,8 @@ type UserRole = 'user' | 'admin';
/** Require the user to prove their role before invoking the controller. */
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 });
c.set('user', user);
if (user && matchesRole(user, role)) {
await next();
@ -70,7 +70,7 @@ function withProof(
opts?: ParseAuthRequestOpts,
): AppMiddleware {
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);
// Prevent people from accidentally using the wrong account. This has no other security implications.
@ -79,7 +79,7 @@ function withProof(
}
if (proof) {
c.set('pubkey', proof.pubkey);
c.set('signer', new ConnectSigner(proof.pubkey));
c.set('proof', proof);
await handler(c, proof, next);
} else {
@ -90,9 +90,16 @@ function withProof(
/** Get the proof over Nostr Connect. */
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 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);
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 { Debug } from '@/deps.ts';
import ExpiringCache from '@/utils/expiring-cache.ts';
const debug = Debug('ditto:middleware:cache');
export const cache = (options: {
export const cacheMiddleware = (options: {
cacheName: string;
expires?: number;
}): MiddlewareHandler => {

View File

@ -1,7 +1,7 @@
import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
const csp = (): AppMiddleware => {
export const cspMiddleware = (): AppMiddleware => {
return async (c, next) => {
const { host, protocol, origin } = Conf.url;
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -26,5 +26,3 @@ const csp = (): AppMiddleware => {
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 { PipePolicy } from '@nostrify/nostrify/policies';
import Debug from '@soapbox/stickynotes/debug';
import { sql } from 'kysely';
import { Conf } from '@/config.ts';
import { db } from '@/db.ts';
import { addRelays } from '@/db/relays.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { Debug, sql } from '@/deps.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { isEphemeralKind } from '@/kinds.ts';
import { DVM } from '@/pipeline/DVM.ts';
import { RelayError } from '@/RelayError.ts';
import { updateStats } from '@/stats.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 { 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 { policyWorker } from '@/workers/policy.ts';
import { TrendsWorker } from '@/workers/trends.ts';
import { verifyEventWorker } from '@/workers/verify.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { lnurlCache } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
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.
* 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 encounterEvent(event, signal)) return;
debug(`NostrEvent<${event.kind}> ${event.id}`);
await hydrateEvent(event, signal);
if (UserPolicy) {
const result = await new UserPolicy().call(event, signal);
debug(JSON.stringify(result));
const [_, _eventId, ok, reason] = result;
if (!ok) {
const [prefix, ...rest] = reason.split(': ');
throw new RelayError(prefix, rest.join(': '));
}
if (event.kind !== 24133) {
await policyFilter(event);
}
await hydrateEvent(event, signal);
await Promise.all([
storeEvent(event, signal),
parseMetadata(event, signal),
processDeletions(event, signal),
DVM.event(event),
trackRelays(event),
trackHashtags(event),
fetchRelatedEvents(event, signal),
fetchRelatedEvents(event),
processMedia(event),
payZap(event, signal),
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. */
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 }]);
cache.event(event);
reqmeister.event(event, { signal });
return !!existing;
}
/** Hydrate the event with the user, if applicable. */
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')
.select('domain')
.where('pubkey', '=', event.pubkey)
@ -89,21 +119,11 @@ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<voi
/** Maybe store the event, if eligible. */
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(
[{ kinds: [5], authors: [Conf.pubkey, event.pubkey], '#e': [event.id], limit: 1 }],
{ 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),
]);
}
await updateStats(event).catch(debug);
await store.event(event, { signal });
}
/** 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;
// 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;
// Get nip05.
@ -128,6 +148,7 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
// Track pubkey domain.
try {
const kysely = await DittoDB.getInstance();
const { domain } = parseNip05(nip05);
await sql`
@ -137,31 +158,12 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
domain = excluded.domain,
last_updated_at = excluded.last_updated_at
WHERE excluded.last_updated_at > pubkey_domains.last_updated_at
`.execute(db);
`.execute(kysely);
} catch (_e) {
// 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. */
async function trackHashtags(event: NostrEvent): Promise<void> {
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. */
async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) {
if (!event.user) {
reqmeister.req({ kinds: [0], authors: [event.pubkey] }, { signal }).catch(() => {});
async function fetchRelatedEvents(event: DittoEvent) {
const cache = await Storages.cache();
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') {
const { count } = await cache.count([{ ids: [id] }]);
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. */
async function streamOut(event: NostrEvent): Promise<void> {
if (isFresh(event)) {
await Storages.pubsub.event(event);
const pubsub = await Storages.pubsub();
await pubsub.event(event);
}
}
/** NIP-20 command line result. */
class RelayError extends Error {
constructor(prefix: 'duplicate' | 'pow' | 'blocked' | 'rate-limited' | 'invalid' | 'error', message: string) {
super(`${prefix}: ${message}`);
}
}
export { handleEvent, RelayError };
export { handleEvent };

View File

@ -3,7 +3,7 @@ import { NIP05, NostrEvent } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import * as pipeline from '@/pipeline.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
export class DVM {
static async event(event: NostrEvent): Promise<void> {
@ -34,7 +34,9 @@ export class DVM {
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],
authors: [admin],
'#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