Merge branch 'main' into feat-create-reports

((( Updating local branch )))
This commit is contained in:
P. Reis 2024-05-02 09:33:59 -03:00
commit 3e6600b5f5
39 changed files with 109 additions and 317 deletions

View file

@ -7,7 +7,6 @@
"debug": "deno run -A --inspect src/server.ts", "debug": "deno run -A --inspect src/server.ts",
"test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A", "test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A",
"check": "deno check src/server.ts", "check": "deno check src/server.ts",
"relays:sync": "deno run -A scripts/relays.ts sync",
"nsec": "deno run scripts/nsec.ts", "nsec": "deno run scripts/nsec.ts",
"admin:event": "deno run -A scripts/admin-event.ts", "admin:event": "deno run -A scripts/admin-event.ts",
"admin:role": "deno run -A scripts/admin-role.ts" "admin:role": "deno run -A scripts/admin-role.ts"

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

@ -1,4 +1,4 @@
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
@ -6,7 +6,6 @@ import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts';
import { uploadFile } from '@/upload.ts'; import { uploadFile } from '@/upload.ts';
@ -198,7 +197,7 @@ const updateCredentialsController: AppController = async (c) => {
} }
const author = await getAuthor(pubkey); const author = await getAuthor(pubkey);
const meta = author ? jsonMetaContentSchema.parse(author.content) : {}; const meta = author ? n.json().pipe(n.metadata()).catch({}).parse(author.content) : {};
const { const {
avatar: avatarFile, avatar: avatarFile,

View file

@ -1,6 +1,8 @@
import { NSchema as n } from '@nostrify/nostrify';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts'; import { serverMetaSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
const instanceController: AppController = async (c) => { const instanceController: AppController = async (c) => {
@ -8,7 +10,7 @@ const instanceController: AppController = async (c) => {
const { signal } = c.req.raw; const { signal } = c.req.raw;
const [event] = await Storages.db.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal }); const [event] = await Storages.db.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal });
const meta = jsonServerMetaSchema.parse(event?.content); const meta = n.json().pipe(serverMetaSchema).catch({}).parse(event?.content);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';

View file

@ -1,3 +1,4 @@
import { NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
@ -6,7 +7,6 @@ import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/p
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { createAdminEvent } from '@/utils/api.ts'; import { createAdminEvent } from '@/utils/api.ts';
import { jsonSchema } from '@/schema.ts';
const frontendConfigController: AppController = async (c) => { const frontendConfigController: AppController = async (c) => {
const configs = await getConfigs(c.req.raw.signal); const configs = await getConfigs(c.req.raw.signal);
@ -75,7 +75,7 @@ async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> {
try { try {
const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content); const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content);
return jsonSchema.pipe(configSchema.array()).catch([]).parse(decrypted); return n.json().pipe(configSchema.array()).catch([]).parse(decrypted);
} catch (_e) { } catch (_e) {
return []; return [];
} }

View file

@ -1,10 +1,9 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts'; import { dedupeEvents } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
@ -20,7 +19,7 @@ const searchQuerySchema = z.object({
type: z.enum(['accounts', 'statuses', 'hashtags']).optional(), type: z.enum(['accounts', 'statuses', 'hashtags']).optional(),
resolve: booleanParamSchema.optional().transform(Boolean), resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false), following: z.boolean().default(false),
account_id: nostrIdSchema.optional(), account_id: n.id().optional(),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
}); });

View file

@ -1,4 +1,4 @@
import { NIP05, NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NIP05, NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import ISO6391 from 'iso-639-1'; import ISO6391 from 'iso-639-1';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
@ -7,7 +7,6 @@ import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { addTag, deleteTag } from '@/tags.ts'; import { addTag, deleteTag } from '@/tags.ts';
import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { renderEventAccounts } from '@/views.ts'; import { renderEventAccounts } from '@/views.ts';
@ -406,7 +405,7 @@ const zapController: AppController = async (c) => {
const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'], signal }); const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'], signal });
const author = target?.author; const author = target?.author;
const meta = jsonMetaContentSchema.parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta); const lnurl = getLnurl(meta);
if (target && lnurl) { if (target && lnurl) {

View file

@ -1,12 +1,14 @@
import { NSchema as n } from '@nostrify/nostrify';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts'; import { serverMetaSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
const relayInfoController: AppController = async (c) => { const relayInfoController: AppController = async (c) => {
const { signal } = c.req.raw; const { signal } = c.req.raw;
const [event] = await Storages.db.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal }); const [event] = await Storages.db.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal });
const meta = jsonServerMetaSchema.parse(event?.content); const meta = n.json().pipe(serverMetaSchema).catch({}).parse(event?.content);
return c.json({ return c.json({
name: meta.name ?? 'Ditto', name: meta.name ?? 'Ditto',

View file

@ -1,14 +1,15 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import {
NostrClientCLOSE,
NostrClientCOUNT,
NostrClientEVENT,
NostrClientMsg,
NostrClientREQ,
NostrEvent,
NostrFilter,
NSchema as n,
} from '@nostrify/nostrify';
import { relayInfoController } from '@/controllers/nostr/relay-info.ts'; import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
import * as pipeline from '@/pipeline.ts'; import * as pipeline from '@/pipeline.ts';
import {
type ClientCLOSE,
type ClientCOUNT,
type ClientEVENT,
type ClientMsg,
clientMsgSchema,
type ClientREQ,
} from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import type { AppController } from '@/app.ts'; import type { AppController } from '@/app.ts';
@ -30,7 +31,7 @@ function connectStream(socket: WebSocket) {
const controllers = new Map<string, AbortController>(); const controllers = new Map<string, AbortController>();
socket.onmessage = (e) => { socket.onmessage = (e) => {
const result = n.json().pipe(clientMsgSchema).safeParse(e.data); const result = n.json().pipe(n.clientMsg()).safeParse(e.data);
if (result.success) { if (result.success) {
handleMsg(result.data); handleMsg(result.data);
} else { } else {
@ -45,7 +46,7 @@ function connectStream(socket: WebSocket) {
}; };
/** Handle client message. */ /** Handle client message. */
function handleMsg(msg: ClientMsg) { function handleMsg(msg: NostrClientMsg) {
switch (msg[0]) { switch (msg[0]) {
case 'REQ': case 'REQ':
handleReq(msg); handleReq(msg);
@ -63,7 +64,7 @@ function connectStream(socket: WebSocket) {
} }
/** Handle REQ. Start a subscription. */ /** Handle REQ. Start a subscription. */
async function handleReq([_, subId, ...rest]: ClientREQ): Promise<void> { async function handleReq([_, subId, ...rest]: NostrClientREQ): Promise<void> {
const filters = prepareFilters(rest); const filters = prepareFilters(rest);
const controller = new AbortController(); const controller = new AbortController();
@ -88,7 +89,7 @@ function connectStream(socket: WebSocket) {
} }
/** Handle EVENT. Store the event. */ /** Handle EVENT. Store the event. */
async function handleEvent([_, event]: ClientEVENT): Promise<void> { async function handleEvent([_, event]: NostrClientEVENT): Promise<void> {
try { try {
// This will store it (if eligible) and run other side-effects. // This will store it (if eligible) and run other side-effects.
await pipeline.handleEvent(event, AbortSignal.timeout(1000)); await pipeline.handleEvent(event, AbortSignal.timeout(1000));
@ -104,7 +105,7 @@ function connectStream(socket: WebSocket) {
} }
/** Handle CLOSE. Close the subscription. */ /** Handle CLOSE. Close the subscription. */
function handleClose([_, subId]: ClientCLOSE): void { function handleClose([_, subId]: NostrClientCLOSE): void {
const controller = controllers.get(subId); const controller = controllers.get(subId);
if (controller) { if (controller) {
controller.abort(); controller.abort();
@ -113,7 +114,7 @@ function connectStream(socket: WebSocket) {
} }
/** Handle COUNT. Return the number of events matching the filters. */ /** Handle COUNT. Return the number of events matching the filters. */
async function handleCount([_, subId, ...rest]: ClientCOUNT): Promise<void> { async function handleCount([_, subId, ...rest]: NostrClientCOUNT): Promise<void> {
const { count } = await Storages.db.count(prepareFilters(rest)); const { count } = await Storages.db.count(prepareFilters(rest));
send(['COUNT', subId, { count, approximate: false }]); send(['COUNT', subId, { count, approximate: false }]);
} }
@ -127,7 +128,7 @@ function connectStream(socket: WebSocket) {
} }
/** Enforce the filters with certain criteria. */ /** Enforce the filters with certain criteria. */
function prepareFilters(filters: ClientREQ[2][]): NostrFilter[] { function prepareFilters(filters: NostrClientREQ[2][]): NostrFilter[] {
return filters.map((filter) => { return filters.map((filter) => {
const narrow = Boolean(filter.ids?.length || filter.authors?.length); const narrow = Boolean(filter.ids?.length || filter.authors?.length);
const search = narrow ? filter.search : `domain:${Conf.url.host} ${filter.search ?? ''}`; const search = narrow ? filter.search : `domain:${Conf.url.host} ${filter.search ?? ''}`;

View file

@ -2,7 +2,6 @@ export interface DittoTables {
events: EventRow; events: EventRow;
events_fts: EventFTSRow; events_fts: EventFTSRow;
tags: TagRow; tags: TagRow;
relays: RelayRow;
unattached_media: UnattachedMediaRow; unattached_media: UnattachedMediaRow;
author_stats: AuthorStatsRow; author_stats: AuthorStatsRow;
event_stats: EventStatsRow; event_stats: EventStatsRow;
@ -45,12 +44,6 @@ interface TagRow {
event_id: string; event_id: string;
} }
interface RelayRow {
url: string;
domain: string;
active: boolean;
}
interface UnattachedMediaRow { interface UnattachedMediaRow {
id: string; id: string;
pubkey: string; pubkey: string;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,37 +0,0 @@
import tldts from 'tldts';
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,9 +1,8 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import stringifyStable from 'fast-stable-stringify'; import stringifyStable from 'fast-stable-stringify';
import { z } from 'zod'; import { z } from 'zod';
import { isReplaceableKind } from '@/kinds.ts'; import { isReplaceableKind } from '@/kinds.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Microfilter to get one specific event by ID. */ /** Microfilter to get one specific event by ID. */
type IdMicrofilter = { ids: [NostrEvent['id']] }; type IdMicrofilter = { ids: [NostrEvent['id']] };
@ -42,8 +41,8 @@ function getMicroFilters(event: NostrEvent): MicroFilter[] {
/** Microfilter schema. */ /** Microfilter schema. */
const microFilterSchema = z.union([ const microFilterSchema = z.union([
z.object({ ids: z.tuple([nostrIdSchema]) }).strict(), z.object({ ids: z.tuple([n.id()]) }).strict(),
z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([nostrIdSchema]) }).strict(), z.object({ kinds: z.tuple([z.literal(0)]), authors: z.tuple([n.id()]) }).strict(),
]); ]);
/** Checks whether the filter is a microfilter. */ /** Checks whether the filter is a microfilter. */

View file

@ -5,7 +5,6 @@ import { sql } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { db } from '@/db.ts'; import { db } from '@/db.ts';
import { addRelays } from '@/db/relays.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { isEphemeralKind } from '@/kinds.ts'; import { isEphemeralKind } from '@/kinds.ts';
@ -14,7 +13,7 @@ import { updateStats } from '@/stats.ts';
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts'; import { getTagSet } from '@/tags.ts';
import { eventAge, isRelay, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; import { eventAge, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts'; import { fetchWorker } from '@/workers/fetch.ts';
import { TrendsWorker } from '@/workers/trends.ts'; import { TrendsWorker } from '@/workers/trends.ts';
import { verifyEventWorker } from '@/workers/verify.ts'; import { verifyEventWorker } from '@/workers/verify.ts';
@ -59,7 +58,6 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
parseMetadata(event, signal), parseMetadata(event, signal),
processDeletions(event, signal), processDeletions(event, signal),
DVM.event(event), DVM.event(event),
trackRelays(event),
trackHashtags(event), trackHashtags(event),
fetchRelatedEvents(event, signal), fetchRelatedEvents(event, signal),
processMedia(event), processMedia(event),
@ -113,7 +111,7 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
if (event.kind !== 0) return; if (event.kind !== 0) return;
// Parse metadata. // Parse metadata.
const metadata = n.json().pipe(n.metadata()).safeParse(event.content); const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
if (!metadata.success) return; if (!metadata.success) return;
// Get nip05. // Get nip05.
@ -183,22 +181,6 @@ async function trackHashtags(event: NostrEvent): Promise<void> {
} }
} }
/** Tracks known relays in the database. */
function trackRelays(event: NostrEvent) {
const relays = new Set<`wss://${string}`>();
event.tags.forEach((tag) => {
if (['p', 'e', 'a'].includes(tag[0]) && isRelay(tag[2])) {
relays.add(tag[2]);
}
if (event.kind === 10002 && tag[0] === 'r' && isRelay(tag[1])) {
relays.add(tag[1]);
}
});
return addRelays([...relays]);
}
/** Queue related events to fetch. */ /** Queue related events to fetch. */
async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) { async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) {
if (!event.user) { if (!event.user) {

View file

@ -1,8 +1,20 @@
import { RelayPoolWorker } from 'nostr-relaypool'; import { RelayPoolWorker } from 'nostr-relaypool';
import { getActiveRelays } from '@/db/relays.ts'; import { Storages } from '@/storages.ts';
import { Conf } from '@/config.ts';
const activeRelays = await getActiveRelays(); const [relayList] = await Storages.db.query([
{ kinds: [10002], authors: [Conf.pubkey], limit: 1 },
]);
const tags = relayList?.tags ?? [];
const activeRelays = tags.reduce((acc, [name, url, marker]) => {
if (name === 'r' && !marker) {
acc.push(url);
}
return acc;
}, []);
console.log(`pool: connecting to ${activeRelays.length} relays.`); console.log(`pool: connecting to ${activeRelays.length} relays.`);

View file

@ -11,16 +11,6 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
)); ));
} }
/** Parses a JSON string into its native type. */
const jsonSchema = z.string().transform((value, ctx) => {
try {
return JSON.parse(value) as unknown;
} catch (_e) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
return z.NEVER;
}
});
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */ /** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
const decode64Schema = z.string().transform((value, ctx) => { const decode64Schema = z.string().transform((value, ctx) => {
try { try {
@ -48,4 +38,4 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value
/** Schema for `File` objects. */ /** Schema for `File` objects. */
const fileSchema = z.custom<File>((value) => value instanceof File); const fileSchema = z.custom<File>((value) => value instanceof File);
export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, jsonSchema, safeUrlSchema }; export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, safeUrlSchema };

View file

@ -1,80 +1,14 @@
import { NSchema as n } from '@nostrify/nostrify';
import { getEventHash, verifyEvent } from 'nostr-tools'; import { getEventHash, verifyEvent } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { jsonSchema, safeUrlSchema } from '@/schema.ts'; import { safeUrlSchema } from '@/schema.ts';
/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */
const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/);
/** Nostr kinds are positive integers. */
const kindSchema = z.number().int().nonnegative();
/** Nostr event schema. */
const eventSchema = z.object({
id: nostrIdSchema,
kind: kindSchema,
tags: z.array(z.array(z.string())),
content: z.string(),
created_at: z.number(),
pubkey: nostrIdSchema,
sig: z.string(),
});
/** Nostr event schema that also verifies the event's signature. */ /** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = eventSchema const signedEventSchema = n.event()
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
.refine(verifyEvent, 'Event signature is invalid'); .refine(verifyEvent, 'Event signature is invalid');
/** Nostr relay filter schema. */
const filterSchema = z.object({
kinds: kindSchema.array().optional(),
ids: nostrIdSchema.array().optional(),
authors: nostrIdSchema.array().optional(),
since: z.number().int().nonnegative().optional(),
until: z.number().int().nonnegative().optional(),
limit: z.number().int().nonnegative().optional(),
search: z.string().optional(),
}).passthrough().and(
z.record(
z.custom<`#${string}`>((val) => typeof val === 'string' && val.startsWith('#')),
z.string().array(),
).catch({}),
);
const clientReqSchema = z.tuple([z.literal('REQ'), z.string().min(1)]).rest(filterSchema);
const clientEventSchema = z.tuple([z.literal('EVENT'), signedEventSchema]);
const clientCloseSchema = z.tuple([z.literal('CLOSE'), z.string().min(1)]);
const clientCountSchema = z.tuple([z.literal('COUNT'), z.string().min(1)]).rest(filterSchema);
/** Client message to a Nostr relay. */
const clientMsgSchema = z.union([
clientReqSchema,
clientEventSchema,
clientCloseSchema,
clientCountSchema,
]);
/** REQ message from client to relay. */
type ClientREQ = z.infer<typeof clientReqSchema>;
/** EVENT message from client to relay. */
type ClientEVENT = z.infer<typeof clientEventSchema>;
/** CLOSE message from client to relay. */
type ClientCLOSE = z.infer<typeof clientCloseSchema>;
/** COUNT message from client to relay. */
type ClientCOUNT = z.infer<typeof clientCountSchema>;
/** Client message to a Nostr relay. */
type ClientMsg = z.infer<typeof clientMsgSchema>;
/** Kind 0 content schema. */
const metaContentSchema = z.object({
name: z.string().optional().catch(undefined),
about: z.string().optional().catch(undefined),
picture: z.string().optional().catch(undefined),
banner: z.string().optional().catch(undefined),
nip05: z.string().optional().catch(undefined),
lud06: z.string().optional().catch(undefined),
lud16: z.string().optional().catch(undefined),
}).partial().passthrough();
/** Media data schema from `"media"` tags. */ /** Media data schema from `"media"` tags. */
const mediaDataSchema = z.object({ const mediaDataSchema = z.object({
blurhash: z.string().optional().catch(undefined), blurhash: z.string().optional().catch(undefined),
@ -88,40 +22,25 @@ const mediaDataSchema = z.object({
}); });
/** Kind 0 content schema for the Ditto server admin user. */ /** Kind 0 content schema for the Ditto server admin user. */
const serverMetaSchema = metaContentSchema.extend({ const serverMetaSchema = n.metadata().and(z.object({
tagline: z.string().optional().catch(undefined), tagline: z.string().optional().catch(undefined),
email: z.string().optional().catch(undefined), email: z.string().optional().catch(undefined),
}); }));
/** Media data from `"media"` tags. */ /** Media data from `"media"` tags. */
type MediaData = z.infer<typeof mediaDataSchema>; type MediaData = z.infer<typeof mediaDataSchema>;
/** Parses kind 0 content from a JSON string. */
const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({});
/** Parses media data from a JSON string. */
const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({});
/** Parses server admin meta from a JSON string. */
const jsonServerMetaSchema = jsonSchema.pipe(serverMetaSchema).catch({});
/** NIP-11 Relay Information Document. */ /** NIP-11 Relay Information Document. */
const relayInfoDocSchema = z.object({ const relayInfoDocSchema = z.object({
name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined),
description: z.string().transform((val) => val.slice(0, 3000)).optional().catch(undefined), description: z.string().transform((val) => val.slice(0, 3000)).optional().catch(undefined),
pubkey: nostrIdSchema.optional().catch(undefined), pubkey: n.id().optional().catch(undefined),
contact: safeUrlSchema.optional().catch(undefined), contact: safeUrlSchema.optional().catch(undefined),
supported_nips: z.number().int().nonnegative().array().optional().catch(undefined), supported_nips: z.number().int().nonnegative().array().optional().catch(undefined),
software: safeUrlSchema.optional().catch(undefined), software: safeUrlSchema.optional().catch(undefined),
icon: safeUrlSchema.optional().catch(undefined), icon: safeUrlSchema.optional().catch(undefined),
}); });
/** NIP-46 signer response. */
const connectResponseSchema = z.object({
id: z.string(),
result: signedEventSchema,
});
/** Parses a Nostr emoji tag. */ /** Parses a Nostr emoji tag. */
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]); const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
@ -129,23 +48,11 @@ const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()
type EmojiTag = z.infer<typeof emojiTagSchema>; type EmojiTag = z.infer<typeof emojiTagSchema>;
export { export {
type ClientCLOSE,
type ClientCOUNT,
type ClientEVENT,
type ClientMsg,
clientMsgSchema,
type ClientREQ,
connectResponseSchema,
type EmojiTag, type EmojiTag,
emojiTagSchema, emojiTagSchema,
filterSchema,
jsonMediaDataSchema,
jsonMetaContentSchema,
jsonServerMetaSchema,
type MediaData, type MediaData,
mediaDataSchema, mediaDataSchema,
metaContentSchema,
nostrIdSchema,
relayInfoDocSchema, relayInfoDocSchema,
serverMetaSchema,
signedEventSchema, signedEventSchema,
}; };

View file

@ -1,4 +1,4 @@
import { NIP50, NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { NIP50, NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import { Kysely, type SelectQueryBuilder } from 'kysely'; import { Kysely, type SelectQueryBuilder } from 'kysely';
@ -7,7 +7,6 @@ import { DittoTables } from '@/db/DittoTables.ts';
import { normalizeFilters } from '@/filter.ts'; import { normalizeFilters } from '@/filter.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts'; import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { purifyEvent } from '@/storages/hydrate.ts'; import { purifyEvent } from '@/storages/hydrate.ts';
import { isNostrId, isURL } from '@/utils.ts'; import { isNostrId, isURL } from '@/utils.ts';
import { abortError } from '@/utils/abort.ts'; import { abortError } from '@/utils/abort.ts';
@ -412,7 +411,7 @@ function buildSearchContent(event: NostrEvent): string {
/** Build search content for a user. */ /** Build search content for a user. */
function buildUserSearchContent(event: NostrEvent): string { function buildUserSearchContent(event: NostrEvent): string {
const { name, nip05, about } = jsonMetaContentSchema.parse(event.content); const { name, nip05, about } = n.json().pipe(n.metadata()).catch({}).parse(event.content);
return [name, nip05, about].filter(Boolean).join('\n'); return [name, nip05, about].filter(Boolean).join('\n');
} }

View file

@ -1,17 +1,13 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { EventTemplate, getEventHash, nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Get the current time in Nostr format. */ /** Get the current time in Nostr format. */
const nostrNow = (): number => Math.floor(Date.now() / 1000); const nostrNow = (): number => Math.floor(Date.now() / 1000);
/** Convenience function to convert Nostr dates into native Date objects. */ /** Convenience function to convert Nostr dates into native Date objects. */
const nostrDate = (seconds: number): Date => new Date(seconds * 1000); const nostrDate = (seconds: number): Date => new Date(seconds * 1000);
/** Pass to sort() to sort events by date. */
const eventDateComparator = (a: NostrEvent, b: NostrEvent): number => b.created_at - a.created_at;
/** Get pubkey from bech32 string, if applicable. */ /** Get pubkey from bech32 string, if applicable. */
function bech32ToPubkey(bech32: string): string | undefined { function bech32ToPubkey(bech32: string): string | undefined {
try { try {
@ -82,74 +78,32 @@ async function sha256(message: string): Promise<string> {
return hashHex; return hashHex;
} }
/** Schema to parse a relay URL. */
const relaySchema = z.string().max(255).startsWith('wss://').url();
/** Check whether the value is a valid relay URL. */
const isRelay = (relay: string): relay is `wss://${string}` => relaySchema.safeParse(relay).success;
/** Deduplicate events by ID. */ /** Deduplicate events by ID. */
function dedupeEvents(events: NostrEvent[]): NostrEvent[] { function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
return [...new Map(events.map((event) => [event.id, event])).values()]; return [...new Map(events.map((event) => [event.id, event])).values()];
} }
/** Return a copy of the event with the given tags removed. */
function stripTags<E extends EventTemplate>(event: E, tags: string[] = []): E {
if (!tags.length) return event;
return {
...event,
tags: event.tags.filter(([name]) => !tags.includes(name)),
};
}
/** Ensure the template and event match on their shared keys. */
function eventMatchesTemplate(event: NostrEvent, template: EventTemplate): boolean {
const whitelist = ['nonce'];
event = stripTags(event, whitelist);
template = stripTags(template, whitelist);
if (template.created_at > event.created_at) {
return false;
}
return getEventHash(event) === getEventHash({
pubkey: event.pubkey,
...template,
created_at: event.created_at,
});
}
/** Test whether the value is a Nostr ID. */ /** Test whether the value is a Nostr ID. */
function isNostrId(value: unknown): boolean { function isNostrId(value: unknown): boolean {
return nostrIdSchema.safeParse(value).success; return n.id().safeParse(value).success;
} }
/** Test whether the value is a URL. */ /** Test whether the value is a URL. */
function isURL(value: unknown): boolean { function isURL(value: unknown): boolean {
try { return z.string().url().safeParse(value).success;
new URL(value as string);
return true;
} catch (_) {
return false;
}
} }
export { export {
bech32ToPubkey, bech32ToPubkey,
dedupeEvents, dedupeEvents,
eventAge, eventAge,
eventDateComparator,
eventMatchesTemplate,
findTag, findTag,
isNostrId, isNostrId,
isRelay,
isURL, isURL,
type Nip05, type Nip05,
nostrDate, nostrDate,
nostrNow, nostrNow,
parseNip05, parseNip05,
relaySchema,
sha256, sha256,
}; };

View file

@ -1,13 +1,13 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { EventTemplate, nip13 } from 'nostr-tools'; import { EventTemplate, nip13 } from 'nostr-tools';
import { decode64Schema, jsonSchema } from '@/schema.ts'; import { decode64Schema } from '@/schema.ts';
import { signedEventSchema } from '@/schemas/nostr.ts'; import { signedEventSchema } from '@/schemas/nostr.ts';
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts'; import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
/** Decode a Nostr event from a base64 encoded string. */ /** Decode a Nostr event from a base64 encoded string. */
const decode64EventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema); const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema);
interface ParseAuthRequestOpts { interface ParseAuthRequestOpts {
/** Max event age (in ms). */ /** Max event age (in ms). */

View file

@ -1,5 +1,6 @@
import { NSchema as n } from '@nostrify/nostrify';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { getPublicKeyPem } from '@/utils/rsa.ts'; import { getPublicKeyPem } from '@/utils/rsa.ts';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
@ -7,7 +8,7 @@ import type { Actor } from '@/schemas/activitypub.ts';
/** Nostr metadata event to ActivityPub actor. */ /** Nostr metadata event to ActivityPub actor. */
async function renderActor(event: NostrEvent, username: string): Promise<Actor | undefined> { async function renderActor(event: NostrEvent, username: string): Promise<Actor | undefined> {
const content = jsonMetaContentSchema.parse(event.content); const content = n.json().pipe(n.metadata()).catch({}).parse(event.content);
return { return {
type: 'Person', type: 'Person',

View file

@ -1,9 +1,9 @@
import { NSchema as n } from '@nostrify/nostrify';
import { nip19, UnsignedEvent } from 'nostr-tools'; import { nip19, UnsignedEvent } from 'nostr-tools';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { lodash } from '@/deps.ts'; import { lodash } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { getLnurl } from '@/utils/lnurl.ts'; import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
@ -28,7 +28,7 @@ async function renderAccount(
about, about,
lud06, lud06,
lud16, lud16,
} = jsonMetaContentSchema.parse(event.content); } = n.json().pipe(n.metadata()).catch({}).parse(event.content);
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey);

View file

@ -1,11 +1,10 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { findReplyTag } from '@/tags.ts'; import { findReplyTag } from '@/tags.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
@ -13,6 +12,7 @@ import { unfurlCardCached } from '@/utils/unfurl.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts';
import { mediaDataSchema } from '@/schemas/nostr.ts';
interface statusOpts { interface statusOpts {
viewerPubkey?: string; viewerPubkey?: string;
@ -78,7 +78,7 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
const mediaTags: DittoAttachment[] = event.tags const mediaTags: DittoAttachment[] = event.tags
.filter((tag) => tag[0] === 'media') .filter((tag) => tag[0] === 'media')
.map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) })); .map(([_, url, json]) => ({ url, data: n.json().pipe(mediaDataSchema).parse(json) }));
const media = [...mediaLinks, ...mediaTags]; const media = [...mediaLinks, ...mediaTags];

View file

@ -1,8 +1,8 @@
import { NSchema } from '@nostrify/nostrify';
import * as Comlink from 'comlink'; import * as Comlink from 'comlink';
import { Sqlite } from '@/deps.ts'; import { Sqlite } from '@/deps.ts';
import { hashtagSchema } from '@/schema.ts'; import { hashtagSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { generateDateRange, Time } from '@/utils/time.ts'; import { generateDateRange, Time } from '@/utils/time.ts';
interface GetTrendingTagsOpts { interface GetTrendingTagsOpts {
@ -102,7 +102,7 @@ export const TrendsWorker = {
}, },
addTagUsages(pubkey: string, hashtags: string[], date = new Date()): void { addTagUsages(pubkey: string, hashtags: string[], date = new Date()): void {
const pubkey8 = nostrIdSchema.parse(pubkey).substring(0, 8); const pubkey8 = NSchema.id().parse(pubkey).substring(0, 8);
const tags = hashtagSchema.array().min(1).parse(hashtags); const tags = hashtagSchema.array().min(1).parse(hashtags);
db.query( db.query(