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",
"test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A",
"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"

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 { z } from 'zod';
@ -6,7 +6,6 @@ import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts';
import { uploadFile } from '@/upload.ts';
@ -198,7 +197,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,

View File

@ -1,6 +1,8 @@
import { NSchema as n } from '@nostrify/nostrify';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { serverMetaSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts';
const instanceController: AppController = async (c) => {
@ -8,7 +10,7 @@ const instanceController: AppController = async (c) => {
const { signal } = c.req.raw;
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`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';

View File

@ -1,3 +1,4 @@
import { NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
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 { 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);
@ -75,7 +75,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

@ -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 { z } from 'zod';
import { AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.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)),
});

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 { nip19 } from 'nostr-tools';
import { z } from 'zod';
@ -7,7 +7,6 @@ import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.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';
@ -406,7 +405,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) {

View File

@ -1,12 +1,14 @@
import { NSchema as n } from '@nostrify/nostrify';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { serverMetaSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts';
const relayInfoController: AppController = async (c) => {
const { signal } = c.req.raw;
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({
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 * 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 type { AppController } from '@/app.ts';
@ -30,7 +31,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 {
@ -45,7 +46,7 @@ function connectStream(socket: WebSocket) {
};
/** Handle client message. */
function handleMsg(msg: ClientMsg) {
function handleMsg(msg: NostrClientMsg) {
switch (msg[0]) {
case 'REQ':
handleReq(msg);
@ -63,7 +64,7 @@ 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();
@ -88,7 +89,7 @@ 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));
@ -104,7 +105,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();
@ -113,7 +114,7 @@ function connectStream(socket: WebSocket) {
}
/** 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));
send(['COUNT', subId, { count, approximate: false }]);
}
@ -127,7 +128,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

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

@ -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 { z } from 'zod';
import { isReplaceableKind } from '@/kinds.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Microfilter to get one specific event by ID. */
type IdMicrofilter = { ids: [NostrEvent['id']] };
@ -42,8 +41,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. */

View File

@ -5,7 +5,6 @@ import { sql } from 'kysely';
import { Conf } from '@/config.ts';
import { db } from '@/db.ts';
import { addRelays } from '@/db/relays.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { isEphemeralKind } from '@/kinds.ts';
@ -14,7 +13,7 @@ import { updateStats } from '@/stats.ts';
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.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 { TrendsWorker } from '@/workers/trends.ts';
import { verifyEventWorker } from '@/workers/verify.ts';
@ -59,7 +58,6 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
parseMetadata(event, signal),
processDeletions(event, signal),
DVM.event(event),
trackRelays(event),
trackHashtags(event),
fetchRelatedEvents(event, signal),
processMedia(event),
@ -113,7 +111,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.
@ -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. */
async function fetchRelatedEvents(event: DittoEvent, signal: AbortSignal) {
if (!event.user) {

View File

@ -1,8 +1,20 @@
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.`);

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 */
const decode64Schema = z.string().transform((value, ctx) => {
try {
@ -48,4 +38,4 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value
/** Schema for `File` objects. */
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 { z } from 'zod';
import { jsonSchema, 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(),
});
import { safeUrlSchema } from '@/schema.ts';
/** 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(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. */
const mediaDataSchema = z.object({
blurhash: z.string().optional().catch(undefined),
@ -88,40 +22,25 @@ const mediaDataSchema = z.object({
});
/** 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),
email: z.string().optional().catch(undefined),
});
}));
/** Media data from `"media"` tags. */
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. */
const relayInfoDocSchema = z.object({
name: z.string().transform((val) => val.slice(0, 30)).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),
supported_nips: z.number().int().nonnegative().array().optional().catch(undefined),
software: 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. */
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>;
export {
type ClientCLOSE,
type ClientCOUNT,
type ClientEVENT,
type ClientMsg,
clientMsgSchema,
type ClientREQ,
connectResponseSchema,
type EmojiTag,
emojiTagSchema,
filterSchema,
jsonMediaDataSchema,
jsonMetaContentSchema,
jsonServerMetaSchema,
type MediaData,
mediaDataSchema,
metaContentSchema,
nostrIdSchema,
relayInfoDocSchema,
serverMetaSchema,
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 { Kysely, type SelectQueryBuilder } from 'kysely';
@ -7,7 +7,6 @@ import { DittoTables } from '@/db/DittoTables.ts';
import { normalizeFilters } from '@/filter.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { purifyEvent } from '@/storages/hydrate.ts';
import { isNostrId, isURL } from '@/utils.ts';
import { abortError } from '@/utils/abort.ts';
@ -412,7 +411,7 @@ function buildSearchContent(event: NostrEvent): string {
/** Build search content for a user. */
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');
}

View File

@ -1,17 +1,13 @@
import { NostrEvent } from '@nostrify/nostrify';
import { EventTemplate, getEventHash, nip19 } from 'nostr-tools';
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Get the current time in Nostr format. */
const nostrNow = (): number => Math.floor(Date.now() / 1000);
/** Convenience function to convert Nostr dates into native Date objects. */
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. */
function bech32ToPubkey(bech32: string): string | undefined {
try {
@ -82,74 +78,32 @@ async function sha256(message: string): Promise<string> {
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. */
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
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. */
function isNostrId(value: unknown): boolean {
return nostrIdSchema.safeParse(value).success;
return n.id().safeParse(value).success;
}
/** Test whether the value is a URL. */
function isURL(value: unknown): boolean {
try {
new URL(value as string);
return true;
} catch (_) {
return false;
}
return z.string().url().safeParse(value).success;
}
export {
bech32ToPubkey,
dedupeEvents,
eventAge,
eventDateComparator,
eventMatchesTemplate,
findTag,
isNostrId,
isRelay,
isURL,
type Nip05,
nostrDate,
nostrNow,
parseNip05,
relaySchema,
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 { decode64Schema, jsonSchema } from '@/schema.ts';
import { decode64Schema } from '@/schema.ts';
import { signedEventSchema } from '@/schemas/nostr.ts';
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts';
import { Time } from '@/utils/time.ts';
/** 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 {
/** Max event age (in ms). */

View File

@ -1,5 +1,6 @@
import { NSchema as n } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { getPublicKeyPem } from '@/utils/rsa.ts';
import type { NostrEvent } from '@nostrify/nostrify';
@ -7,7 +8,7 @@ import type { Actor } from '@/schemas/activitypub.ts';
/** Nostr metadata event to ActivityPub actor. */
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 {
type: 'Person',

View File

@ -1,9 +1,9 @@
import { NSchema as n } from '@nostrify/nostrify';
import { nip19, UnsignedEvent } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { lodash } from '@/deps.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
@ -28,7 +28,7 @@ async function renderAccount(
about,
lud06,
lud16,
} = jsonMetaContentSchema.parse(event.content);
} = n.json().pipe(n.metadata()).catch({}).parse(event.content);
const npub = nip19.npubEncode(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 { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
import { Storages } from '@/storages.ts';
import { findReplyTag } from '@/tags.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 { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
import { mediaDataSchema } from '@/schemas/nostr.ts';
interface statusOpts {
viewerPubkey?: string;
@ -78,7 +78,7 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise<any> {
const mediaTags: DittoAttachment[] = event.tags
.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];

View File

@ -1,8 +1,8 @@
import { NSchema } from '@nostrify/nostrify';
import * as Comlink from 'comlink';
import { Sqlite } from '@/deps.ts';
import { hashtagSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { generateDateRange, Time } from '@/utils/time.ts';
interface GetTrendingTagsOpts {
@ -102,7 +102,7 @@ export const TrendsWorker = {
},
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);
db.query(