From aa1515e7e9785a9d5b584209daa283c76f49b2a7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 13:00:11 -0600 Subject: [PATCH 01/47] Remove accidental HSTS header from packs/ route --- src/app.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index b18ba281..5b12b223 100644 --- a/src/app.ts +++ b/src/app.ts @@ -414,8 +414,7 @@ app.get('/instance/*', publicFiles); // Packs contains immutable static files app.get('/packs/*', async (c, next) => { - c.header('Cache-Control', 'public, max-age=31536000, immutable'); - c.header('Strict-Transport-Security', '"max-age=31536000" always'); + c.header('Cache-Control', 'max-age=31536000, public, immutable'); await next(); }, publicFiles); From b8dbc432abd3beba92629f0bcb3e638bae92ab3d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 13:00:43 -0600 Subject: [PATCH 02/47] Add Cache-Control headers to nostr.json responses --- src/controllers/well-known/nostr.ts | 38 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts index b6b7af09..5a2017bb 100644 --- a/src/controllers/well-known/nostr.ts +++ b/src/controllers/well-known/nostr.ts @@ -1,36 +1,50 @@ +import { NostrJson } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { localNip05Lookup } from '@/utils/nip05.ts'; -const nameSchema = z.string().min(1).regex(/^\w+$/); +const nameSchema = z.string().min(1).regex(/^[\w.-]+$/); +const emptyResult: NostrJson = { names: {}, relays: {} }; /** * Serves NIP-05's nostr.json. * https://github.com/nostr-protocol/nips/blob/master/05.md */ const nostrController: AppController = async (c) => { + // If there are no query parameters, this will always return an empty result. + if (!Object.entries(c.req.queries()).length) { + c.header('Cache-Control', 'max-age=31536000, public, immutable'); + return c.json(emptyResult); + } + const store = c.get('store'); const result = nameSchema.safeParse(c.req.query('name')); const name = result.success ? result.data : undefined; - const pointer = name ? await localNip05Lookup(store, name) : undefined; if (!name || !pointer) { - return c.json({ names: {}, relays: {} }); + // Not found, cache for 5 minutes. + c.header('Cache-Control', 'max-age=300, public'); + return c.json(emptyResult); } - const { pubkey, relays } = pointer; + const { pubkey, relays = [] } = pointer; - return c.json({ - names: { - [name]: pubkey, - }, - relays: { - [pubkey]: relays, - }, - }); + // It's found, so cache for 12 hours. + c.header('Cache-Control', 'max-age=43200, public'); + + return c.json( + { + names: { + [name]: pubkey, + }, + relays: { + [pubkey]: relays, + }, + } satisfies NostrJson, + ); }; export { nostrController }; From 66f7853c3b76204ff243cb7b392ccfa583d9f370 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 13:04:40 -0600 Subject: [PATCH 03/47] Add cacheControlMiddleware --- src/app.ts | 6 +- src/middleware/cacheControlMiddleware.test.ts | 33 ++++++ src/middleware/cacheControlMiddleware.ts | 102 ++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/middleware/cacheControlMiddleware.test.ts create mode 100644 src/middleware/cacheControlMiddleware.ts diff --git a/src/app.ts b/src/app.ts index 5b12b223..98119602 100644 --- a/src/app.ts +++ b/src/app.ts @@ -131,6 +131,7 @@ import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well import { nostrController } from '@/controllers/well-known/nostr.ts'; import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; +import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts'; @@ -413,10 +414,7 @@ app.get('/images/*', publicFiles, staticFiles); app.get('/instance/*', publicFiles); // Packs contains immutable static files -app.get('/packs/*', async (c, next) => { - c.header('Cache-Control', 'max-age=31536000, public, immutable'); - await next(); -}, publicFiles); +app.get('/packs/*', cacheControlMiddleware({ maxAge: 31536000, public: true, immutable: true }), publicFiles); // Site index app.get('/', frontendController, indexController); diff --git a/src/middleware/cacheControlMiddleware.test.ts b/src/middleware/cacheControlMiddleware.test.ts new file mode 100644 index 00000000..dd3e0acf --- /dev/null +++ b/src/middleware/cacheControlMiddleware.test.ts @@ -0,0 +1,33 @@ +import { Hono } from '@hono/hono'; +import { assertEquals } from '@std/assert'; + +import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; + +Deno.test('cacheControlMiddleware with multiple options', async () => { + const app = new Hono(); + + app.use(cacheControlMiddleware({ + maxAge: 31536000, + public: true, + immutable: true, + })); + + app.get('/', (c) => c.text('OK')); + + const response = await app.request('/'); + const cacheControl = response.headers.get('Cache-Control'); + + assertEquals(cacheControl, 'max-age=31536000, public, immutable'); +}); + +Deno.test('cacheControlMiddleware with no options does not add header', async () => { + const app = new Hono(); + + app.use(cacheControlMiddleware({})); + app.get('/', (c) => c.text('OK')); + + const response = await app.request('/'); + const cacheControl = response.headers.get('Cache-Control'); + + assertEquals(cacheControl, null); +}); diff --git a/src/middleware/cacheControlMiddleware.ts b/src/middleware/cacheControlMiddleware.ts new file mode 100644 index 00000000..59557e4f --- /dev/null +++ b/src/middleware/cacheControlMiddleware.ts @@ -0,0 +1,102 @@ +import { MiddlewareHandler } from '@hono/hono'; + +/** + * Options for the `cacheControlMiddleware` middleware. + * + * NOTE: All numerical values are in **seconds**. + * + * See the definitions of [fresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#fresh_and_stale_based_on_age) and [stale](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#fresh_and_stale_based_on_age). + */ +export interface CacheControlMiddlewareOpts { + /** Indicates that the response remains fresh until _N_ seconds after the response is generated. */ + maxAge?: number; + /** Indicates how long the response remains fresh in a shared cache. */ + sMaxAge?: number; + /** Indicates that the response can be stored in caches, but the response must be validated with the origin server before each reuse, even when the cache is disconnected from the origin server. */ + noCache?: boolean; + /** Indicates that the response can be stored in caches and can be reused while fresh. */ + mustRevalidate?: boolean; + /** Equivalent of `must-revalidate`, but specifically for shared caches only. */ + proxyRevalidate?: boolean; + /** Indicates that any caches of any kind (private or shared) should not store this response. */ + noStore?: boolean; + /** Indicates that the response can be stored only in a private cache (e.g. local caches in browsers). */ + private?: boolean; + /** Indicates that the response can be stored in a shared cache. */ + public?: boolean; + /** Indicates that a cache should store the response only if it understands the requirements for caching based on status code. */ + mustUnderstand?: boolean; + /** Indicates that any intermediary (regardless of whether it implements a cache) shouldn't transform the response contents. */ + noTransform?: boolean; + /** Indicates that the response will not be updated while it's fresh. */ + immutable?: boolean; + /** Indicates that the cache could reuse a stale response while it revalidates it to a cache. */ + staleWhileRevalidate?: number; + /** indicates that the cache can reuse a stale response when an upstream server generates an error, or when the error is generated locally. */ + staleIfError?: number; +} + +/** Adds a [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header to the response. */ +export function cacheControlMiddleware(opts: CacheControlMiddlewareOpts): MiddlewareHandler { + return async (c, next) => { + const directives: string[] = []; + + if (typeof opts.maxAge === 'number') { + directives.push(`max-age=${opts.maxAge}`); + } + + if (typeof opts.sMaxAge === 'number') { + directives.push(`s-maxage=${opts.sMaxAge}`); + } + + if (opts.noCache) { + directives.push('no-cache'); + } + + if (opts.mustRevalidate) { + directives.push('must-revalidate'); + } + + if (opts.proxyRevalidate) { + directives.push('proxy-revalidate'); + } + + if (opts.noStore) { + directives.push('no-store'); + } + + if (opts.private) { + directives.push('private'); + } + + if (opts.public) { + directives.push('public'); + } + + if (opts.mustUnderstand) { + directives.push('must-understand'); + } + + if (opts.noTransform) { + directives.push('no-transform'); + } + + if (opts.immutable) { + directives.push('immutable'); + } + + if (typeof opts.staleWhileRevalidate === 'number') { + directives.push(`stale-while-revalidate=${opts.staleWhileRevalidate}`); + } + + if (typeof opts.staleIfError === 'number') { + directives.push(`stale-if-error=${opts.staleIfError}`); + } + + if (directives.length) { + c.header('Cache-Control', directives.join(', ')); + } + + await next(); + }; +} From 871222ee4e631ba1017a9f0e4be0fa7619ecea9a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 14:11:10 -0600 Subject: [PATCH 04/47] Add Cache-Control headers to a bunch of routes --- src/app.ts | 98 ++++++++++++++++++++++++----- src/controllers/api/fallback.ts | 13 +++- src/controllers/frontend.ts | 1 + src/controllers/well-known/nostr.ts | 8 +-- 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/app.ts b/src/app.ts index 98119602..24d9afe8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -199,15 +199,39 @@ app.use( app.get('/metrics', metricsController); -app.get('/.well-known/nodeinfo', nodeInfoController); +app.get( + '/.well-known/nodeinfo', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + nodeInfoController, +); app.get('/.well-known/nostr.json', nostrController); -app.get('/nodeinfo/:version', nodeInfoSchemaController); -app.get('/manifest.webmanifest', manifestController); +app.get( + '/nodeinfo/:version', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + nodeInfoSchemaController, +); +app.get( + '/manifest.webmanifest', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + manifestController, +); -app.get('/api/v1/instance', instanceV1Controller); -app.get('/api/v2/instance', instanceV2Controller); -app.get('/api/v1/instance/extended_description', instanceDescriptionController); +app.get( + '/api/v1/instance', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + instanceV1Controller, +); +app.get( + '/api/v2/instance', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + instanceV2Controller, +); +app.get( + '/api/v1/instance/extended_description', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + instanceDescriptionController, +); app.get('/api/v1/apps/verify_credentials', appCredentialsController); app.post('/api/v1/apps', createAppController); @@ -296,12 +320,28 @@ app.get('/api/v1/preferences', preferencesController); app.get('/api/v1/search', searchController); app.get('/api/v2/search', searchController); -app.get('/api/pleroma/frontend_configurations', frontendConfigController); +app.get( + '/api/pleroma/frontend_configurations', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + frontendConfigController, +); app.get('/api/v1/trends/statuses', rateLimitMiddleware(8, Time.seconds(30)), trendingStatusesController); -app.get('/api/v1/trends/links', trendingLinksController); -app.get('/api/v1/trends/tags', trendingTagsController); -app.get('/api/v1/trends', trendingTagsController); +app.get( + '/api/v1/trends/links', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + trendingLinksController, +); +app.get( + '/api/v1/trends/tags', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + trendingTagsController, +); +app.get( + '/api/v1/trends', + cacheControlMiddleware({ maxAge: 300, staleWhileRevalidate: 300, staleIfError: 21600, public: true }), + trendingTagsController, +); app.get('/api/v1/suggestions', suggestionsV1Controller); app.get('/api/v2/suggestions', suggestionsV2Controller); @@ -345,7 +385,11 @@ app.post( captchaVerifyController, ); -app.get('/api/v1/ditto/zap_splits', getZapSplitsController); +app.get( + '/api/v1/ditto/zap_splits', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, public: true }), + getZapSplitsController, +); app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController); app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); @@ -409,12 +453,36 @@ app.get('/timeline/*', frontendController); // Known static file routes app.get('/sw.js', publicFiles); -app.get('/favicon.ico', publicFiles, staticFiles); -app.get('/images/*', publicFiles, staticFiles); -app.get('/instance/*', publicFiles); +app.get( + '/favicon.ico', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + publicFiles, + staticFiles, +); +app.get( + '/images/*', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + publicFiles, + staticFiles, +); +app.get( + '/instance/*', + cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), + publicFiles, +); // Packs contains immutable static files -app.get('/packs/*', cacheControlMiddleware({ maxAge: 31536000, public: true, immutable: true }), publicFiles); +app.get( + '/packs/*', + cacheControlMiddleware({ + maxAge: 31536000, + staleWhileRevalidate: 86400, + staleIfError: 21600, + public: true, + immutable: true, + }), + publicFiles, +); // Site index app.get('/', frontendController, indexController); diff --git a/src/controllers/api/fallback.ts b/src/controllers/api/fallback.ts index 0e98ac79..5794c544 100644 --- a/src/controllers/api/fallback.ts +++ b/src/controllers/api/fallback.ts @@ -1,6 +1,13 @@ -import { type Context } from '@hono/hono'; +import { Handler } from '@hono/hono'; -const emptyArrayController = (c: Context) => c.json([]); -const notImplementedController = (c: Context) => Promise.resolve(c.json({ error: 'Not implemented' }, 404)); +const emptyArrayController: Handler = (c) => { + c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=60'); + return c.json([]); +}; + +const notImplementedController: Handler = (c) => { + c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=60'); + return c.json({ error: 'Not implemented' }, 404); +}; export { emptyArrayController, notImplementedController }; diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index b1a3bba4..31a19b92 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -31,6 +31,7 @@ export const frontendController: AppMiddleware = async (c, next) => { try { const entities = await getEntities(params ?? {}); const meta = renderMetadata(c.req.url, entities); + c.header('Cache-Control', 'max-age=30, public, stale-while-revalidate=30'); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { console.log(`Error building meta tags: ${e}`); diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts index 5a2017bb..4fd366e7 100644 --- a/src/controllers/well-known/nostr.ts +++ b/src/controllers/well-known/nostr.ts @@ -14,7 +14,7 @@ const emptyResult: NostrJson = { names: {}, relays: {} }; const nostrController: AppController = async (c) => { // If there are no query parameters, this will always return an empty result. if (!Object.entries(c.req.queries()).length) { - c.header('Cache-Control', 'max-age=31536000, public, immutable'); + c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400'); return c.json(emptyResult); } @@ -26,14 +26,14 @@ const nostrController: AppController = async (c) => { if (!name || !pointer) { // Not found, cache for 5 minutes. - c.header('Cache-Control', 'max-age=300, public'); + c.header('Cache-Control', 'max-age=300, public, stale-while-revalidate=30'); return c.json(emptyResult); } const { pubkey, relays = [] } = pointer; - // It's found, so cache for 12 hours. - c.header('Cache-Control', 'max-age=43200, public'); + // It's found, so cache for 6 hours. + c.header('Cache-Control', 'max-age=21600, public, stale-while-revalidate=3600'); return c.json( { From afa0a337d30326561c1cef185f0a8eb3a0bf7924 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 14:59:29 -0600 Subject: [PATCH 05/47] Add a default cache-control header of no-store --- src/app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.ts b/src/app.ts index 24d9afe8..7b4316a3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -177,6 +177,7 @@ const publicFiles = serveStatic({ root: './public/' }); /** Static files provided by the Ditto repo, checked into git. */ const staticFiles = serveStatic({ root: './static/' }); +app.use('*', cacheControlMiddleware({ noStore: true })); app.use('*', rateLimitMiddleware(300, Time.minutes(5))); app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug)); From 3fdd6e2213c44c003d38ffae0d159ade7935334a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 15:23:48 -0600 Subject: [PATCH 06/47] Force no-store header on server error and rate limit responses --- src/controllers/error.ts | 2 ++ src/middleware/rateLimitMiddleware.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/controllers/error.ts b/src/controllers/error.ts index f7806db8..120e78a9 100644 --- a/src/controllers/error.ts +++ b/src/controllers/error.ts @@ -2,6 +2,8 @@ import { ErrorHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; export const errorHandler: ErrorHandler = (err, c) => { + c.header('Cache-Control', 'no-store'); + if (err instanceof HTTPException) { if (err.res) { return err.res; diff --git a/src/middleware/rateLimitMiddleware.ts b/src/middleware/rateLimitMiddleware.ts index 689f7cee..e21d8000 100644 --- a/src/middleware/rateLimitMiddleware.ts +++ b/src/middleware/rateLimitMiddleware.ts @@ -9,6 +9,10 @@ export function rateLimitMiddleware(limit: number, windowMs: number): Middleware return rateLimiter({ limit, windowMs, + handler: (c) => { + c.header('Cache-Control', 'no-store'); + return c.text('Too many requests, please try again later.', 429); + }, skip: (c) => !c.req.header('x-real-ip'), keyGenerator: (c) => c.req.header('x-real-ip')!, }); From 8083148d038b0cb7b4e96248e6bf9de94b138387 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 15:27:24 -0600 Subject: [PATCH 07/47] Don't include ratelimit headers on the default bucket --- src/app.ts | 2 +- src/middleware/rateLimitMiddleware.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7b4316a3..9ef0f70b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -178,7 +178,7 @@ const publicFiles = serveStatic({ root: './public/' }); const staticFiles = serveStatic({ root: './static/' }); app.use('*', cacheControlMiddleware({ noStore: true })); -app.use('*', rateLimitMiddleware(300, Time.minutes(5))); +app.use('*', rateLimitMiddleware(300, Time.minutes(5), false)); app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug)); app.use('/.well-known/*', metricsMiddleware, logger(debug)); diff --git a/src/middleware/rateLimitMiddleware.ts b/src/middleware/rateLimitMiddleware.ts index e21d8000..e7a43328 100644 --- a/src/middleware/rateLimitMiddleware.ts +++ b/src/middleware/rateLimitMiddleware.ts @@ -4,11 +4,12 @@ import { rateLimiter } from 'hono-rate-limiter'; /** * Rate limit middleware for Hono, based on [`hono-rate-limiter`](https://github.com/rhinobase/hono-rate-limiter). */ -export function rateLimitMiddleware(limit: number, windowMs: number): MiddlewareHandler { +export function rateLimitMiddleware(limit: number, windowMs: number, includeHeaders?: boolean): MiddlewareHandler { // @ts-ignore Mismatched hono versions. return rateLimiter({ limit, windowMs, + standardHeaders: includeHeaders, handler: (c) => { c.header('Cache-Control', 'no-store'); return c.text('Too many requests, please try again later.', 429); From 64370c23e38061e1b04a19386c7aabde3aed96bf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 15:42:19 -0600 Subject: [PATCH 08/47] caddy: remove unnecessary hsts header --- installation/Caddyfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/installation/Caddyfile b/installation/Caddyfile index 79630c2a..191031d4 100644 --- a/installation/Caddyfile +++ b/installation/Caddyfile @@ -15,8 +15,7 @@ example.com { handle /packs/* { root * /opt/ditto/public - header Cache-Control "public, max-age=31536000, immutable" - header Strict-Transport-Security "max-age=31536000" + header Cache-Control "max-age=31536000, public, immutable" file_server } From 218604aa56e6fe1250bbd52ef204c9c0b51e0b0e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 23 Jan 2025 22:43:54 -0600 Subject: [PATCH 09/47] Move ratelimitMiddleware below metricsMiddleware, try adding a stricter ratelimit --- src/app.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 9ef0f70b..393cd995 100644 --- a/src/app.ts +++ b/src/app.ts @@ -178,7 +178,6 @@ const publicFiles = serveStatic({ root: './public/' }); const staticFiles = serveStatic({ root: './static/' }); app.use('*', cacheControlMiddleware({ noStore: true })); -app.use('*', rateLimitMiddleware(300, Time.minutes(5), false)); app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug)); app.use('/.well-known/*', metricsMiddleware, logger(debug)); @@ -188,6 +187,12 @@ app.use('/oauth/*', metricsMiddleware, logger(debug)); app.get('/api/v1/streaming', metricsMiddleware, streamingController); app.get('/relay', metricsMiddleware, relayController); +app.use( + '*', + rateLimitMiddleware(30, Time.seconds(5), false), + rateLimitMiddleware(300, Time.minutes(5), false), +); + app.use( '*', cspMiddleware(), From 5dc840e14e835f61fa782b9e5cb6ef15337e48ce Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 24 Jan 2025 04:27:56 -0600 Subject: [PATCH 10/47] Avoid applying ratelimit to /packs --- src/app.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app.ts b/src/app.ts index 393cd995..85fa9301 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; +import { every } from '@hono/hono/combine'; import { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; import { logger } from '@hono/hono/logger'; @@ -179,20 +180,19 @@ const staticFiles = serveStatic({ root: './static/' }); app.use('*', cacheControlMiddleware({ noStore: true })); -app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug)); -app.use('/.well-known/*', metricsMiddleware, logger(debug)); -app.use('/nodeinfo/*', metricsMiddleware, logger(debug)); -app.use('/oauth/*', metricsMiddleware, logger(debug)); - -app.get('/api/v1/streaming', metricsMiddleware, streamingController); -app.get('/relay', metricsMiddleware, relayController); - -app.use( - '*', +const ratelimit = every( rateLimitMiddleware(30, Time.seconds(5), false), rateLimitMiddleware(300, Time.minutes(5), false), ); +app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware, logger(debug)); +app.use('/.well-known/*', metricsMiddleware, ratelimit, logger(debug)); +app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logger(debug)); +app.use('/oauth/*', metricsMiddleware, ratelimit, logger(debug)); + +app.get('/api/v1/streaming', metricsMiddleware, ratelimit, streamingController); +app.get('/relay', metricsMiddleware, ratelimit, relayController); + app.use( '*', cspMiddleware(), @@ -491,10 +491,10 @@ app.get( ); // Site index -app.get('/', frontendController, indexController); +app.get('/', ratelimit, frontendController, indexController); // Fallback -app.get('*', publicFiles, staticFiles, frontendController); +app.get('*', publicFiles, staticFiles, ratelimit, frontendController); app.onError(errorHandler); From 75be90694c1050ceb50097ac5139aad23833c1a6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 01:42:20 -0600 Subject: [PATCH 11/47] Always inject og metadata, but add generous cache headers --- src/app.ts | 6 +----- src/config.ts | 8 -------- src/controllers/frontend.ts | 18 +++++------------- src/controllers/site.ts | 17 ----------------- 4 files changed, 6 insertions(+), 43 deletions(-) delete mode 100644 src/controllers/site.ts diff --git a/src/app.ts b/src/app.ts index 85fa9301..c303de0c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -126,7 +126,6 @@ import { translateController } from '@/controllers/api/translate.ts'; import { errorHandler } from '@/controllers/error.ts'; import { frontendController } from '@/controllers/frontend.ts'; import { metricsController } from '@/controllers/metrics.ts'; -import { indexController } from '@/controllers/site.ts'; import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; @@ -490,10 +489,7 @@ app.get( publicFiles, ); -// Site index -app.get('/', ratelimit, frontendController, indexController); - -// Fallback +app.get('/', ratelimit, frontendController); app.get('*', publicFiles, staticFiles, ratelimit, frontendController); app.onError(errorHandler); diff --git a/src/config.ts b/src/config.ts index 3c1e8923..d4033d80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -267,14 +267,6 @@ class Conf { static get cronEnabled(): boolean { return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true; } - /** Crawler User-Agent regex to render link previews to. */ - static get crawlerRegex(): RegExp { - return new RegExp( - Deno.env.get('CRAWLER_REGEX') || - 'googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp|mastodon|pleroma|Discordbot|AhrefsBot|SEMrushBot|MJ12bot|SeekportBot|Synapse|Matrix', - 'i', - ); - } /** User-Agent to use when fetching link previews. Pretend to be Facebook by default. */ static get fetchUserAgent(): string { return Deno.env.get('DITTO_FETCH_USER_AGENT') ?? 'facebookexternalhit'; diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index 31a19b92..fca8c0a6 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -1,5 +1,4 @@ import { AppMiddleware } from '@/app.ts'; -import { Conf } from '@/config.ts'; import { Stickynotes } from '@soapbox/stickynotes'; import { Storages } from '@/storages.ts'; import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts'; @@ -15,23 +14,17 @@ const console = new Stickynotes('ditto:frontend'); /** Placeholder to find & replace with metadata. */ const META_PLACEHOLDER = '' as const; -export const frontendController: AppMiddleware = async (c, next) => { +export const frontendController: AppMiddleware = async (c) => { + c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800'); + try { const content = await Deno.readTextFile(new URL('../../public/index.html', import.meta.url)); - const ua = c.req.header('User-Agent'); - console.debug('ua', ua); - - if (!Conf.crawlerRegex.test(ua ?? '')) { - return c.html(content); - } - if (content.includes(META_PLACEHOLDER)) { const params = getPathParams(c.req.path); try { const entities = await getEntities(params ?? {}); const meta = renderMetadata(c.req.url, entities); - c.header('Cache-Control', 'max-age=30, public, stale-while-revalidate=30'); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { console.log(`Error building meta tags: ${e}`); @@ -39,9 +32,8 @@ export const frontendController: AppMiddleware = async (c, next) => { } } return c.html(content); - } catch (e) { - console.log(e); - await next(); + } catch { + return c.notFound(); } }; diff --git a/src/controllers/site.ts b/src/controllers/site.ts deleted file mode 100644 index 751e60ef..00000000 --- a/src/controllers/site.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Conf } from '@/config.ts'; - -import type { AppController } from '@/app.ts'; - -/** Landing page controller. */ -const indexController: AppController = (c) => { - const { origin } = Conf.url; - - return c.text(`Please connect with a Mastodon client: - - ${origin} - -Ditto -`); -}; - -export { indexController }; From a8b8b8b427587b2e4070891908786eea57eb077f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 01:46:08 -0600 Subject: [PATCH 12/47] Reduce default FIREHOSE_CONCURRENCY to 1 --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index d4033d80..514ba7e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -248,7 +248,7 @@ class Conf { } /** Number of events the firehose is allowed to process at one time before they have to wait in a queue. */ static get firehoseConcurrency(): number { - return Math.ceil(Number(Deno.env.get('FIREHOSE_CONCURRENCY') ?? (Conf.pg.poolSize * 0.25))); + return Math.ceil(Number(Deno.env.get('FIREHOSE_CONCURRENCY') ?? 1)); } /** Nostr event kinds of events to listen for on the firehose. */ static get firehoseKinds(): number[] { From b8d288868d57a90abb9f4873c77a73a8f4286da1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 01:46:31 -0600 Subject: [PATCH 13/47] Turn on NOTIFY_ENABLED by default (now that it's optimized) --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 514ba7e0..b82ef5ea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -261,7 +261,7 @@ class Conf { * This would make Nostr events inserted directly into Postgres available to the streaming API and relay. */ static get notifyEnabled(): boolean { - return optionalBooleanSchema.parse(Deno.env.get('NOTIFY_ENABLED')) ?? false; + return optionalBooleanSchema.parse(Deno.env.get('NOTIFY_ENABLED')) ?? true; } /** Whether to enable Ditto cron jobs. */ static get cronEnabled(): boolean { From 12de164a4fac6ca6ad03e4ce2e28663ccc669461 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 13:36:49 -0600 Subject: [PATCH 14/47] Add a custom RateLimiter implementation --- src/utils/ratelimiter/MemoryRateLimiter.ts | 77 ++++++++++++++++++++++ src/utils/ratelimiter/RateLimitError.ts | 10 +++ src/utils/ratelimiter/types.ts | 12 ++++ 3 files changed, 99 insertions(+) create mode 100644 src/utils/ratelimiter/MemoryRateLimiter.ts create mode 100644 src/utils/ratelimiter/RateLimitError.ts create mode 100644 src/utils/ratelimiter/types.ts diff --git a/src/utils/ratelimiter/MemoryRateLimiter.ts b/src/utils/ratelimiter/MemoryRateLimiter.ts new file mode 100644 index 00000000..b3f14d81 --- /dev/null +++ b/src/utils/ratelimiter/MemoryRateLimiter.ts @@ -0,0 +1,77 @@ +import { RateLimitError } from './RateLimitError.ts'; +import { RateLimiter, RateLimiterClient } from './types.ts'; + +interface MemoryRateLimiterOpts { + limit: number; + window: number; +} + +export class MemoryRateLimiter implements RateLimiter { + private iid: number; + + private previous = new Map(); + private current = new Map(); + + constructor(private opts: MemoryRateLimiterOpts) { + this.iid = setInterval(() => { + this.previous = this.current; + this.current = new Map(); + }, opts.window); + } + + get limit(): number { + return this.opts.limit; + } + + get window(): number { + return this.opts.window; + } + + client(key: string): RateLimiterClient { + const curr = this.current.get(key); + const prev = this.previous.get(key); + + if (curr) { + return curr; + } + + if (prev) { + this.current.set(key, prev); + this.previous.delete(key); + return prev; + } + + const next = new MemoryRateLimiterClient(this); + this.current.set(key, next); + return next; + } + + [Symbol.dispose](): void { + clearInterval(this.iid); + } +} + +class MemoryRateLimiterClient implements RateLimiterClient { + private _hits: number = 0; + readonly resetAt: Date; + + constructor(private limiter: MemoryRateLimiter) { + this.resetAt = new Date(Date.now() + limiter.window); + } + + get hits(): number { + return this._hits; + } + + get remaining(): number { + return this.limiter.limit - this.hits; + } + + hit(n: number = 1): void { + this._hits += n; + + if (this.remaining < 0) { + throw new RateLimitError(this.limiter, this); + } + } +} diff --git a/src/utils/ratelimiter/RateLimitError.ts b/src/utils/ratelimiter/RateLimitError.ts new file mode 100644 index 00000000..ce21af72 --- /dev/null +++ b/src/utils/ratelimiter/RateLimitError.ts @@ -0,0 +1,10 @@ +import { RateLimiter, RateLimiterClient } from './types.ts'; + +export class RateLimitError extends Error { + constructor( + readonly limiter: RateLimiter, + readonly client: RateLimiterClient, + ) { + super('Rate limit exceeded'); + } +} diff --git a/src/utils/ratelimiter/types.ts b/src/utils/ratelimiter/types.ts new file mode 100644 index 00000000..c1a6b2f0 --- /dev/null +++ b/src/utils/ratelimiter/types.ts @@ -0,0 +1,12 @@ +export interface RateLimiter extends Disposable { + readonly limit: number; + readonly window: number; + client(key: string): RateLimiterClient; +} + +export interface RateLimiterClient { + readonly hits: number; + readonly resetAt: Date; + readonly remaining: number; + hit(n?: number): void; +} From 68a0ef664819c9d2755ba3eaa369b3be182a3ce8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 15:20:52 -0600 Subject: [PATCH 15/47] Add ratelimiter tests --- .../ratelimiter/MemoryRateLimiter.test.ts | 31 +++++++++++++ src/utils/ratelimiter/MemoryRateLimiter.ts | 2 +- .../ratelimiter/MultiRateLimiter.test.ts | 39 ++++++++++++++++ src/utils/ratelimiter/MultiRateLimiter.ts | 45 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/utils/ratelimiter/MemoryRateLimiter.test.ts create mode 100644 src/utils/ratelimiter/MultiRateLimiter.test.ts create mode 100644 src/utils/ratelimiter/MultiRateLimiter.ts diff --git a/src/utils/ratelimiter/MemoryRateLimiter.test.ts b/src/utils/ratelimiter/MemoryRateLimiter.test.ts new file mode 100644 index 00000000..2da6b2d1 --- /dev/null +++ b/src/utils/ratelimiter/MemoryRateLimiter.test.ts @@ -0,0 +1,31 @@ +import { assertEquals, assertThrows } from '@std/assert'; + +import { MemoryRateLimiter } from './MemoryRateLimiter.ts'; +import { RateLimitError } from './RateLimitError.ts'; + +Deno.test('MemoryRateLimiter', async (t) => { + const limit = 5; + const window = 100; + + using limiter = new MemoryRateLimiter({ limit, window }); + + await t.step('can hit up to limit', () => { + for (let i = 0; i < limit; i++) { + const client = limiter.client('test'); + assertEquals(client.hits, i); + client.hit(); + } + }); + + await t.step('throws when hit if limit exceeded', () => { + assertThrows(() => limiter.client('test').hit(), RateLimitError); + }); + + await t.step('can hit after window resets', async () => { + await new Promise((resolve) => setTimeout(resolve, window + 1)); + + const client = limiter.client('test'); + assertEquals(client.hits, 0); + client.hit(); + }); +}); diff --git a/src/utils/ratelimiter/MemoryRateLimiter.ts b/src/utils/ratelimiter/MemoryRateLimiter.ts index b3f14d81..0eaa5540 100644 --- a/src/utils/ratelimiter/MemoryRateLimiter.ts +++ b/src/utils/ratelimiter/MemoryRateLimiter.ts @@ -35,7 +35,7 @@ export class MemoryRateLimiter implements RateLimiter { return curr; } - if (prev) { + if (prev && prev.resetAt > new Date()) { this.current.set(key, prev); this.previous.delete(key); return prev; diff --git a/src/utils/ratelimiter/MultiRateLimiter.test.ts b/src/utils/ratelimiter/MultiRateLimiter.test.ts new file mode 100644 index 00000000..3cfa4696 --- /dev/null +++ b/src/utils/ratelimiter/MultiRateLimiter.test.ts @@ -0,0 +1,39 @@ +import { assertEquals, assertThrows } from '@std/assert'; + +import { MemoryRateLimiter } from './MemoryRateLimiter.ts'; +import { MultiRateLimiter } from './MultiRateLimiter.ts'; + +Deno.test('MultiRateLimiter', async (t) => { + using limiter1 = new MemoryRateLimiter({ limit: 5, window: 100 }); + using limiter2 = new MemoryRateLimiter({ limit: 8, window: 200 }); + + const limiter = new MultiRateLimiter([limiter1, limiter2]); + + await t.step('can hit up to first limit', () => { + for (let i = 0; i < limiter1.limit; i++) { + const client = limiter.client('test'); + assertEquals(client.hits, i); + client.hit(); + } + }); + + await t.step('throws when hit if first limit exceeded', () => { + assertThrows(() => limiter.client('test').hit(), Error); + }); + + await t.step('can hit up to second limit after the first window resets', async () => { + await new Promise((resolve) => setTimeout(resolve, limiter1.window + 1)); + + const limit = limiter2.limit - limiter1.limit - 1; + + for (let i = 0; i < limit; i++) { + const client = limiter.client('test'); + assertEquals(client.hits, i); + client.hit(); + } + }); + + await t.step('throws when hit if second limit exceeded', () => { + assertThrows(() => limiter.client('test').hit(), Error); + }); +}); diff --git a/src/utils/ratelimiter/MultiRateLimiter.ts b/src/utils/ratelimiter/MultiRateLimiter.ts new file mode 100644 index 00000000..dc9b62a7 --- /dev/null +++ b/src/utils/ratelimiter/MultiRateLimiter.ts @@ -0,0 +1,45 @@ +import { RateLimiter, RateLimiterClient } from './types.ts'; + +export class MultiRateLimiter { + constructor(private limiters: RateLimiter[]) {} + + client(key: string): RateLimiterClient { + return new MultiRateLimiterClient(key, this.limiters); + } +} + +class MultiRateLimiterClient implements RateLimiterClient { + constructor(private key: string, private limiters: RateLimiter[]) { + if (!limiters.length) { + throw new Error('No limiters provided'); + } + } + + get hits(): number { + return this.limiters[0].client(this.key).hits; + } + + get resetAt(): Date { + return this.limiters[0].client(this.key).resetAt; + } + + get remaining(): number { + return this.limiters[0].client(this.key).remaining; + } + + hit(n?: number): void { + let error: unknown; + + for (const limiter of this.limiters) { + try { + limiter.client(this.key).hit(n); + } catch (e) { + error ??= e; + } + } + + if (error instanceof Error) { + throw error; + } + } +} From 43a47770f4d8cc44c7e8142c796aee3f5f6a78ca Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 15:21:16 -0600 Subject: [PATCH 16/47] relay: stricter rate limits --- src/controllers/nostr/relay.ts | 62 +++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 74bd8a56..93ffb199 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,6 +1,6 @@ import { Stickynotes } from '@soapbox/stickynotes'; -import TTLCache from '@isaacs/ttlcache'; import { + NKinds, NostrClientCLOSE, NostrClientCOUNT, NostrClientEVENT, @@ -19,14 +19,27 @@ import { RelayError } from '@/RelayError.ts'; import { Storages } from '@/storages.ts'; import { Time } from '@/utils/time.ts'; import { purifyEvent } from '@/utils/purify.ts'; +import { MemoryRateLimiter } from '@/utils/ratelimiter/MemoryRateLimiter.ts'; +import { MultiRateLimiter } from '@/utils/ratelimiter/MultiRateLimiter.ts'; +import { RateLimiter } from '@/utils/ratelimiter/types.ts'; /** Limit of initial events returned for a subscription. */ const FILTER_LIMIT = 100; -const LIMITER_WINDOW = Time.minutes(1); -const LIMITER_LIMIT = 300; - -const limiter = new TTLCache(); +const limiters = { + msg: new MemoryRateLimiter({ limit: 300, window: Time.minutes(1) }), + req: new MultiRateLimiter([ + new MemoryRateLimiter({ limit: 15, window: Time.seconds(5) }), + new MemoryRateLimiter({ limit: 300, window: Time.minutes(5) }), + new MemoryRateLimiter({ limit: 1000, window: Time.hours(1) }), + ]), + event: new MultiRateLimiter([ + new MemoryRateLimiter({ limit: 10, window: Time.seconds(10) }), + new MemoryRateLimiter({ limit: 100, window: Time.hours(1) }), + new MemoryRateLimiter({ limit: 500, window: Time.days(1) }), + ]), + ephemeral: new MemoryRateLimiter({ limit: 30, window: Time.seconds(10) }), +}; /** Connections for metrics purposes. */ const connections = new Set(); @@ -43,15 +56,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { }; socket.onmessage = (e) => { - if (ip) { - const count = limiter.get(ip) ?? 0; - limiter.set(ip, count + 1, { ttl: LIMITER_WINDOW }); - - if (count > LIMITER_LIMIT) { - socket.close(1008, 'Rate limit exceeded'); - return; - } - } + assertRateLimit(limiters.msg); if (typeof e.data !== 'string') { socket.close(1003, 'Invalid message'); @@ -77,6 +82,18 @@ function connectStream(socket: WebSocket, ip: string | undefined) { } }; + function assertRateLimit(limiter: Pick): void { + if (ip) { + const client = limiter.client(ip); + try { + client.hit(); + } catch (error) { + socket.close(1008, 'Rate limit exceeded'); + throw error; + } + } + } + /** Handle client message. */ function handleMsg(msg: NostrClientMsg) { switch (msg[0]) { @@ -97,6 +114,8 @@ function connectStream(socket: WebSocket, ip: string | undefined) { /** Handle REQ. Start a subscription. */ async function handleReq([_, subId, ...filters]: NostrClientREQ): Promise { + assertRateLimit(limiters.req); + const controller = new AbortController(); controllers.get(subId)?.abort(); controllers.set(subId, controller); @@ -136,6 +155,13 @@ function connectStream(socket: WebSocket, ip: string | undefined) { /** Handle EVENT. Store the event. */ async function handleEvent([_, event]: NostrClientEVENT): Promise { relayEventsCounter.inc({ kind: event.kind.toString() }); + + if (NKinds.ephemeral(event.kind)) { + assertRateLimit(limiters.ephemeral); + } else { + assertRateLimit(limiters.event); + } + try { // This will store it (if eligible) and run other side-effects. await pipeline.handleEvent(purifyEvent(event), { source: 'relay', signal: AbortSignal.timeout(1000) }); @@ -161,6 +187,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { /** Handle COUNT. Return the number of events matching the filters. */ async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise { + assertRateLimit(limiters.req); const store = await Storages.db(); const { count } = await store.count(filters, { timeout: Conf.db.timeouts.relay }); send(['COUNT', subId, { count, approximate: false }]); @@ -188,8 +215,11 @@ const relayController: AppController = (c, next) => { const ip = c.req.header('x-real-ip'); if (ip) { - const count = limiter.get(ip) ?? 0; - if (count > LIMITER_LIMIT) { + const remaining = Object + .values(limiters) + .reduce((acc, limiter) => Math.min(acc, limiter.client(ip).remaining), Infinity); + + if (remaining < 0) { return c.json({ error: 'Rate limit exceeded' }, 429); } } From fd312032a4188ddfd658355933169dafc8406f87 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 15:31:49 -0600 Subject: [PATCH 17/47] MultiRateLimiter: ensure the active limiter is used for ratelimit values --- src/utils/ratelimiter/MultiRateLimiter.test.ts | 2 ++ src/utils/ratelimiter/MultiRateLimiter.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/utils/ratelimiter/MultiRateLimiter.test.ts b/src/utils/ratelimiter/MultiRateLimiter.test.ts index 3cfa4696..9b1fd648 100644 --- a/src/utils/ratelimiter/MultiRateLimiter.test.ts +++ b/src/utils/ratelimiter/MultiRateLimiter.test.ts @@ -34,6 +34,8 @@ Deno.test('MultiRateLimiter', async (t) => { }); await t.step('throws when hit if second limit exceeded', () => { + assertEquals(limiter.client('test').limiter, limiter1); assertThrows(() => limiter.client('test').hit(), Error); + assertEquals(limiter.client('test').limiter, limiter2); }); }); diff --git a/src/utils/ratelimiter/MultiRateLimiter.ts b/src/utils/ratelimiter/MultiRateLimiter.ts index dc9b62a7..14b23142 100644 --- a/src/utils/ratelimiter/MultiRateLimiter.ts +++ b/src/utils/ratelimiter/MultiRateLimiter.ts @@ -3,7 +3,7 @@ import { RateLimiter, RateLimiterClient } from './types.ts'; export class MultiRateLimiter { constructor(private limiters: RateLimiter[]) {} - client(key: string): RateLimiterClient { + client(key: string): MultiRateLimiterClient { return new MultiRateLimiterClient(key, this.limiters); } } @@ -15,16 +15,22 @@ class MultiRateLimiterClient implements RateLimiterClient { } } + /** Returns the _active_ limiter, which is either the first exceeded or the first. */ + get limiter(): RateLimiter { + const exceeded = this.limiters.find((limiter) => limiter.client(this.key).remaining < 0); + return exceeded ?? this.limiters[0]; + } + get hits(): number { - return this.limiters[0].client(this.key).hits; + return this.limiter.client(this.key).hits; } get resetAt(): Date { - return this.limiters[0].client(this.key).resetAt; + return this.limiter.client(this.key).resetAt; } get remaining(): number { - return this.limiters[0].client(this.key).remaining; + return this.limiter.client(this.key).remaining; } hit(n?: number): void { From 7601cfa4309f0e55f16e9b3a5f53b28ba817d0fc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 15:37:09 -0600 Subject: [PATCH 18/47] Don't throw inside the websocket callbacks because that crashes the whole application --- src/controllers/nostr/relay.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 93ffb199..6b1c2fbc 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -56,7 +56,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { }; socket.onmessage = (e) => { - assertRateLimit(limiters.msg); + if (rateLimited(limiters.msg)) return; if (typeof e.data !== 'string') { socket.close(1003, 'Invalid message'); @@ -82,16 +82,17 @@ function connectStream(socket: WebSocket, ip: string | undefined) { } }; - function assertRateLimit(limiter: Pick): void { + function rateLimited(limiter: Pick): boolean { if (ip) { const client = limiter.client(ip); try { client.hit(); - } catch (error) { + } catch { socket.close(1008, 'Rate limit exceeded'); - throw error; + return true; } } + return false; } /** Handle client message. */ @@ -114,7 +115,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { /** Handle REQ. Start a subscription. */ async function handleReq([_, subId, ...filters]: NostrClientREQ): Promise { - assertRateLimit(limiters.req); + if (rateLimited(limiters.req)) return; const controller = new AbortController(); controllers.get(subId)?.abort(); @@ -156,11 +157,8 @@ function connectStream(socket: WebSocket, ip: string | undefined) { async function handleEvent([_, event]: NostrClientEVENT): Promise { relayEventsCounter.inc({ kind: event.kind.toString() }); - if (NKinds.ephemeral(event.kind)) { - assertRateLimit(limiters.ephemeral); - } else { - assertRateLimit(limiters.event); - } + const limiter = NKinds.ephemeral(event.kind) ? limiters.ephemeral : limiters.event; + if (rateLimited(limiter)) return; try { // This will store it (if eligible) and run other side-effects. @@ -187,7 +185,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { /** Handle COUNT. Return the number of events matching the filters. */ async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise { - assertRateLimit(limiters.req); + if (rateLimited(limiters.req)) return; const store = await Storages.db(); const { count } = await store.count(filters, { timeout: Conf.db.timeouts.relay }); send(['COUNT', subId, { count, approximate: false }]); From 452088386cd30dc1210ab75a1c10cad4df57ed5d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Jan 2025 17:53:38 -0600 Subject: [PATCH 19/47] Upgrade @nostrify/db --- deno.json | 2 +- deno.lock | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/deno.json b/deno.json index 3c9eef1a..b16d8f08 100644 --- a/deno.json +++ b/deno.json @@ -45,7 +45,7 @@ "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", "@negrel/webpush": "jsr:@negrel/webpush@^0.3.0", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", - "@nostrify/db": "jsr:@nostrify/db@^0.36.1", + "@nostrify/db": "jsr:@nostrify/db@^0.36.2", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.37.0", "@nostrify/policies": "jsr:@nostrify/policies@^0.36.1", "@nostrify/types": "jsr:@nostrify/types@^0.36.0", diff --git a/deno.lock b/deno.lock index 2781f233..47501da6 100644 --- a/deno.lock +++ b/deno.lock @@ -30,7 +30,7 @@ "jsr:@lambdalisue/async@^2.1.1": "2.1.1", "jsr:@negrel/http-ece@0.6.0": "0.6.0", "jsr:@negrel/webpush@0.3": "0.3.0", - "jsr:@nostrify/db@~0.36.1": "0.36.1", + "jsr:@nostrify/db@~0.36.2": "0.36.2", "jsr:@nostrify/nostrify@0.31": "0.31.0", "jsr:@nostrify/nostrify@0.32": "0.32.0", "jsr:@nostrify/nostrify@0.36": "0.36.2", @@ -115,6 +115,7 @@ "npm:lru-cache@^10.2.0": "10.2.2", "npm:lru-cache@^10.2.2": "10.2.2", "npm:nostr-tools@2.5.1": "2.5.1", + "npm:nostr-tools@^2.10.4": "2.10.4", "npm:nostr-tools@^2.5.0": "2.5.1", "npm:nostr-tools@^2.7.0": "2.7.0", "npm:nostr-wasm@0.1": "0.1.0", @@ -347,13 +348,13 @@ "jsr:@std/path@0.224.0" ] }, - "@nostrify/db@0.36.1": { - "integrity": "b65b89ca6fe98d9dbcc0402b5c9c07b8430c2c91f84ba4128ff2eeed70c3d49f", + "@nostrify/db@0.36.2": { + "integrity": "6bf079b44fcb3ff5a85eadf9a9d4eb677fc770f1c80ad966602aa3d9dd8c88e8", "dependencies": [ - "jsr:@nostrify/nostrify@0.36", - "jsr:@nostrify/types@0.35", + "jsr:@nostrify/nostrify@0.37", + "jsr:@nostrify/types@0.36", "npm:kysely@~0.27.3", - "npm:nostr-tools@^2.7.0" + "npm:nostr-tools@^2.10.4" ] }, "@nostrify/nostrify@0.22.4": { @@ -1361,6 +1362,18 @@ "whatwg-url@5.0.0" ] }, + "nostr-tools@2.10.4": { + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", + "dependencies": [ + "@noble/ciphers", + "@noble/curves@1.2.0", + "@noble/hashes@1.3.1", + "@scure/base@1.1.1", + "@scure/bip32@1.3.1", + "@scure/bip39@1.2.1", + "nostr-wasm" + ] + }, "nostr-tools@2.5.1": { "integrity": "sha512-bpkhGGAhdiCN0irfV+xoH3YP5CQeOXyXzUq7SYeM6D56xwTXZCPEmBlUGqFVfQidvRsoVeVxeAiOXW2c2HxoRQ==", "dependencies": [ @@ -2335,7 +2348,7 @@ "jsr:@hono/hono@^4.4.6", "jsr:@lambdalisue/async@^2.1.1", "jsr:@negrel/webpush@0.3", - "jsr:@nostrify/db@~0.36.1", + "jsr:@nostrify/db@~0.36.2", "jsr:@nostrify/nostrify@0.37", "jsr:@nostrify/policies@~0.36.1", "jsr:@nostrify/types@0.36", From 224d7bfef920950535fad42756984ca9373508b6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 11:27:57 -0600 Subject: [PATCH 20/47] Add SyslogIdentifier=ditto to systemd unit --- installation/ditto.service | 1 + 1 file changed, 1 insertion(+) diff --git a/installation/ditto.service b/installation/ditto.service index eb6b3425..0423b0fa 100644 --- a/installation/ditto.service +++ b/installation/ditto.service @@ -6,6 +6,7 @@ After=network-online.target [Service] Type=simple User=ditto +SyslogIdentifier=ditto WorkingDirectory=/opt/ditto ExecStart=/usr/local/bin/deno task start Restart=on-failure From 2a6f954df101b847a8c41fef8c20316bbdeb4cea Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 15:43:29 -0600 Subject: [PATCH 21/47] Add logi, start using it in KyselyLogger --- deno.json | 1 + deno.lock | 5 +++++ src/db/KyselyLogger.ts | 36 +++++++++++++++++++++++++----------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/deno.json b/deno.json index b16d8f08..fca56fff 100644 --- a/deno.json +++ b/deno.json @@ -52,6 +52,7 @@ "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", + "@soapbox/logi": "jsr:@soapbox/logi@^0.1.2", "@soapbox/safe-fetch": "jsr:@soapbox/safe-fetch@^2.0.0", "@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0", "@std/assert": "jsr:@std/assert@^0.225.1", diff --git a/deno.lock b/deno.lock index 47501da6..9972204a 100644 --- a/deno.lock +++ b/deno.lock @@ -49,6 +49,7 @@ "jsr:@nostrify/types@0.36": "0.36.0", "jsr:@nostrify/types@~0.30.1": "0.30.1", "jsr:@soapbox/kysely-pglite@1": "1.0.0", + "jsr:@soapbox/logi@~0.1.2": "0.1.2", "jsr:@soapbox/safe-fetch@2": "2.0.0", "jsr:@soapbox/stickynotes@0.4": "0.4.0", "jsr:@std/assert@0.223": "0.223.0", @@ -526,6 +527,9 @@ "npm:kysely@~0.27.4" ] }, + "@soapbox/logi@0.1.2": { + "integrity": "2fbba613a4dbc092e534097729a729ace772fd67a855cd049e1139ee1facd89f" + }, "@soapbox/safe-fetch@2.0.0": { "integrity": "f451d686501c76a0faa058fe9d2073676282a8a42c3b93c59159eb9191f11b5f", "dependencies": [ @@ -2353,6 +2357,7 @@ "jsr:@nostrify/policies@~0.36.1", "jsr:@nostrify/types@0.36", "jsr:@soapbox/kysely-pglite@1", + "jsr:@soapbox/logi@~0.1.2", "jsr:@soapbox/safe-fetch@2", "jsr:@soapbox/stickynotes@0.4", "jsr:@std/assert@~0.225.1", diff --git a/src/db/KyselyLogger.ts b/src/db/KyselyLogger.ts index 514f44a4..d101dd48 100644 --- a/src/db/KyselyLogger.ts +++ b/src/db/KyselyLogger.ts @@ -1,22 +1,36 @@ -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import { Logger } from 'kysely'; + import { dbQueriesCounter, dbQueryDurationHistogram } from '@/metrics.ts'; /** Log the SQL for queries. */ export const KyselyLogger: Logger = (event) => { - const console = new Stickynotes('ditto:sql'); - const { query, queryDurationMillis } = event; - const { sql, parameters } = query; + const { sql } = query; - const queryDurationSeconds = queryDurationMillis / 1000; + const duration = queryDurationMillis / 1000; dbQueriesCounter.inc(); - dbQueryDurationHistogram.observe(queryDurationSeconds); + dbQueryDurationHistogram.observe(duration); - console.debug( - sql, - JSON.stringify(parameters), - `\x1b[90m(${(queryDurationSeconds / 1000).toFixed(2)}s)\x1b[0m`, - ); + /** Parameters serialized to JSON. */ + const parameters = query.parameters.map((parameter) => { + try { + return JSON.stringify(parameter); + } catch { + return String(parameter); + } + }); + + if (event.level === 'query') { + logi({ level: 'debug', ns: 'ditto.sql', sql, parameters, duration }); + } + + if (event.level === 'error') { + const error = event.error instanceof Error + ? { name: event.error.name, message: event.error.message } + : { name: 'unknown', message: 'Unknown error' }; + + logi({ level: 'error', ns: 'ditto.sql', sql, parameters, error, duration }); + } }; From 2165e649bcb250d4d4ace45d723e76bd6beb842f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 18:11:32 -0600 Subject: [PATCH 22/47] Remove Stickynotes, replace all occurrences of console.log with logi --- deno.json | 3 +-- deno.lock | 13 ++++------- src/DittoPush.ts | 7 +++++- src/app.ts | 13 +++++------ src/controllers/api/admin.ts | 10 +++++++-- src/controllers/api/media.ts | 6 +++-- src/controllers/api/streaming.ts | 8 +++---- src/controllers/api/trends.ts | 38 +++++++++++++++++++++++++++----- src/controllers/error.ts | 5 ++++- src/controllers/frontend.ts | 8 +++---- src/controllers/nostr/relay.ts | 9 ++++---- src/db/DittoDB.ts | 27 +++++++++++++++++------ src/db/KyselyLogger.ts | 7 ++---- src/firehose.ts | 9 ++++---- src/middleware/logiMiddleware.ts | 18 +++++++++++++++ src/notify.ts | 12 +++++----- src/pipeline.ts | 18 +++++++-------- src/queries.ts | 9 ++++---- src/sentry.ts | 5 ++++- src/server.ts | 9 +++++++- src/storages.ts | 9 +++++++- src/storages/EventsDB.ts | 13 +++++------ src/storages/search-store.ts | 11 +++++---- src/trends.ts | 20 +++++++++-------- src/utils/api.ts | 6 ++--- src/utils/favicon.ts | 33 ++++++++++++++++++--------- src/utils/lnurl.ts | 18 +++++++-------- src/utils/log.ts | 10 +++++++++ src/utils/lookup.ts | 4 ---- src/utils/nip05.ts | 26 +++++++++++----------- src/utils/unfurl.ts | 15 +++++++------ src/utils/upload.ts | 7 +++--- src/workers/fetch.worker.ts | 8 +++---- src/workers/policy.ts | 28 ++++++++++++++++++----- 34 files changed, 273 insertions(+), 169 deletions(-) create mode 100644 src/middleware/logiMiddleware.ts create mode 100644 src/utils/log.ts diff --git a/deno.json b/deno.json index fca56fff..d4864fe6 100644 --- a/deno.json +++ b/deno.json @@ -52,9 +52,8 @@ "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", - "@soapbox/logi": "jsr:@soapbox/logi@^0.1.2", + "@soapbox/logi": "jsr:@soapbox/logi@^0.1.3", "@soapbox/safe-fetch": "jsr:@soapbox/safe-fetch@^2.0.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", diff --git a/deno.lock b/deno.lock index 9972204a..f6ac0798 100644 --- a/deno.lock +++ b/deno.lock @@ -49,9 +49,8 @@ "jsr:@nostrify/types@0.36": "0.36.0", "jsr:@nostrify/types@~0.30.1": "0.30.1", "jsr:@soapbox/kysely-pglite@1": "1.0.0", - "jsr:@soapbox/logi@~0.1.2": "0.1.2", + "jsr:@soapbox/logi@~0.1.3": "0.1.3", "jsr:@soapbox/safe-fetch@2": "2.0.0", - "jsr:@soapbox/stickynotes@0.4": "0.4.0", "jsr:@std/assert@0.223": "0.223.0", "jsr:@std/assert@0.224": "0.224.0", "jsr:@std/assert@~0.213.1": "0.213.1", @@ -527,8 +526,8 @@ "npm:kysely@~0.27.4" ] }, - "@soapbox/logi@0.1.2": { - "integrity": "2fbba613a4dbc092e534097729a729ace772fd67a855cd049e1139ee1facd89f" + "@soapbox/logi@0.1.3": { + "integrity": "1b974f26550d2eba08171f2374ae39876b55a5e7c2780a08b8d04cda86f6f5f2" }, "@soapbox/safe-fetch@2.0.0": { "integrity": "f451d686501c76a0faa058fe9d2073676282a8a42c3b93c59159eb9191f11b5f", @@ -536,9 +535,6 @@ "npm:tldts@^6.1.61" ] }, - "@soapbox/stickynotes@0.4.0": { - "integrity": "60bfe61ab3d7e04bf708273b1e2d391a59534bdf29e54160e98d7afd328ca1ec" - }, "@std/assert@0.213.1": { "integrity": "24c28178b30c8e0782c18e8e94ea72b16282207569cdd10ffb9d1d26f2edebfe" }, @@ -2357,9 +2353,8 @@ "jsr:@nostrify/policies@~0.36.1", "jsr:@nostrify/types@0.36", "jsr:@soapbox/kysely-pglite@1", - "jsr:@soapbox/logi@~0.1.2", + "jsr:@soapbox/logi@~0.1.3", "jsr:@soapbox/safe-fetch@2", - "jsr:@soapbox/stickynotes@0.4", "jsr:@std/assert@~0.225.1", "jsr:@std/cli@0.223", "jsr:@std/crypto@0.224", diff --git a/src/DittoPush.ts b/src/DittoPush.ts index 364f08ae..b8e105d9 100644 --- a/src/DittoPush.ts +++ b/src/DittoPush.ts @@ -1,4 +1,5 @@ import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush'; +import { logi } from '@soapbox/logi'; import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; @@ -20,7 +21,11 @@ export class DittoPush { vapidKeys: keys, }); } else { - console.warn('VAPID keys are not set. Push notifications will be disabled.'); + logi({ + level: 'warn', + ns: 'ditto.push', + message: 'VAPID keys are not set. Push notifications will be disabled.', + }); } })(); } diff --git a/src/app.ts b/src/app.ts index c303de0c..8ff16dc1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,9 +2,7 @@ import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, Middle import { every } from '@hono/hono/combine'; import { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; -import { logger } from '@hono/hono/logger'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; import { Kysely } from 'kysely'; import '@/startup.ts'; @@ -142,6 +140,7 @@ import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; +import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; export interface AppEnv extends HonoEnv { Variables: { @@ -170,8 +169,6 @@ type AppController = Handler({ strict: false }); -const debug = Debug('ditto:http'); - /** User-provided files in the gitignored `public/` directory. */ const publicFiles = serveStatic({ root: './public/' }); /** Static files provided by the Ditto repo, checked into git. */ @@ -184,10 +181,10 @@ const ratelimit = every( rateLimitMiddleware(300, Time.minutes(5), false), ); -app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware, logger(debug)); -app.use('/.well-known/*', metricsMiddleware, ratelimit, logger(debug)); -app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logger(debug)); -app.use('/oauth/*', metricsMiddleware, ratelimit, logger(debug)); +app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware, logiMiddleware); +app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware); +app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware); +app.use('/oauth/*', metricsMiddleware, ratelimit, logiMiddleware); app.get('/api/v1/streaming', metricsMiddleware, ratelimit, streamingController); app.get('/relay', metricsMiddleware, ratelimit, relayController); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index 2a9dae1f..73af90dd 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -9,6 +9,8 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; +import { logi } from '@soapbox/logi'; +import { errorJson } from '@/utils/log.ts'; const adminAccountQuerySchema = z.object({ local: booleanParamSchema.optional(), @@ -148,11 +150,15 @@ const adminActionController: AppController = async (c) => { if (data.type === 'suspend') { n.disabled = true; n.suspended = true; - store.remove([{ authors: [authorId] }]).catch(console.warn); + store.remove([{ authors: [authorId] }]).catch((e: unknown) => { + logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); + }); } if (data.type === 'revoke_name') { n.revoke_name = true; - store.remove([{ kinds: [30360], authors: [Conf.pubkey], '#p': [authorId] }]).catch(console.warn); + store.remove([{ kinds: [30360], authors: [Conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => { + logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); + }); } await updateUser(authorId, n, c); diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 7dc398ca..fc309cdf 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -1,11 +1,13 @@ +import { logi } from '@soapbox/logi'; import { z } from 'zod'; import { AppController } from '@/app.ts'; +import { dittoUploads } from '@/DittoUploads.ts'; import { fileSchema } from '@/schema.ts'; import { parseBody } from '@/utils/api.ts'; import { renderAttachment } from '@/views/mastodon/attachments.ts'; +import { errorJson } from '@/utils/log.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { dittoUploads } from '@/DittoUploads.ts'; const mediaBodySchema = z.object({ file: fileSchema, @@ -32,7 +34,7 @@ const mediaController: AppController = async (c) => { const media = await uploadFile(c, file, { pubkey, description }, signal); return c.json(renderAttachment(media)); } catch (e) { - console.error(e); + logi({ level: 'error', ns: 'ditto.api.media', error: errorJson(e) }); return c.json({ error: 'Failed to upload file.' }, 500); } }; diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index cad87e0b..f067cd43 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,6 +1,6 @@ import TTLCache from '@isaacs/ttlcache'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; @@ -15,13 +15,12 @@ import { getFeedPubkeys } from '@/queries.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { getTokenHash } from '@/utils/auth.ts'; +import { errorJson } from '@/utils/log.ts'; import { bech32ToPubkey, Time } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; import { HTTPException } from '@hono/hono/http-exception'; -const console = new Stickynotes('ditto:streaming'); - /** * Streaming timelines/categories. * https://docs.joinmastodon.org/methods/streaming/#streams @@ -101,7 +100,6 @@ const streamingController: AppController = async (c) => { function send(e: StreamingEvent) { if (socket.readyState === WebSocket.OPEN) { - console.debug('send', e.event, e.payload); streamingServerMessagesCounter.inc(); socket.send(JSON.stringify(e)); } @@ -130,7 +128,7 @@ const streamingController: AppController = async (c) => { } } } catch (e) { - console.debug('streaming error:', e); + logi({ level: 'error', ns: 'ditto.streaming', message: 'Error in streaming', error: errorJson(e) }); } } diff --git a/src/controllers/api/trends.ts b/src/controllers/api/trends.ts index a7906192..6b064ed0 100644 --- a/src/controllers/api/trends.ts +++ b/src/controllers/api/trends.ts @@ -1,4 +1,5 @@ import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; +import { logi } from '@soapbox/logi'; import { z } from 'zod'; import { AppController } from '@/app.ts'; @@ -9,10 +10,17 @@ import { Storages } from '@/storages.ts'; import { generateDateRange, Time } from '@/utils/time.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { paginated } from '@/utils/api.ts'; +import { errorJson } from '@/utils/log.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -let trendingHashtagsCache = getTrendingHashtags().catch((e) => { - console.error(`Failed to get trending hashtags: ${e}`); +let trendingHashtagsCache = getTrendingHashtags().catch((e: unknown) => { + logi({ + level: 'error', + ns: 'ditto.trends.api', + type: 'tags', + message: 'Failed to get trending hashtags', + error: errorJson(e), + }); return Promise.resolve([]); }); @@ -21,7 +29,13 @@ Deno.cron('update trending hashtags cache', '35 * * * *', async () => { const trends = await getTrendingHashtags(); trendingHashtagsCache = Promise.resolve(trends); } catch (e) { - console.error(e); + logi({ + level: 'error', + ns: 'ditto.trends.api', + type: 'tags', + message: 'Failed to get trending hashtags', + error: errorJson(e), + }); } }); @@ -57,8 +71,14 @@ async function getTrendingHashtags() { }); } -let trendingLinksCache = getTrendingLinks().catch((e) => { - console.error(`Failed to get trending links: ${e}`); +let trendingLinksCache = getTrendingLinks().catch((e: unknown) => { + logi({ + level: 'error', + ns: 'ditto.trends.api', + type: 'links', + message: 'Failed to get trending links', + error: errorJson(e), + }); return Promise.resolve([]); }); @@ -67,7 +87,13 @@ Deno.cron('update trending links cache', '50 * * * *', async () => { const trends = await getTrendingLinks(); trendingLinksCache = Promise.resolve(trends); } catch (e) { - console.error(e); + logi({ + level: 'error', + ns: 'ditto.trends.api', + type: 'links', + message: 'Failed to get trending links', + error: errorJson(e), + }); } }); diff --git a/src/controllers/error.ts b/src/controllers/error.ts index 120e78a9..a6a802ea 100644 --- a/src/controllers/error.ts +++ b/src/controllers/error.ts @@ -1,5 +1,8 @@ import { ErrorHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; +import { logi } from '@soapbox/logi'; + +import { errorJson } from '@/utils/log.ts'; export const errorHandler: ErrorHandler = (err, c) => { c.header('Cache-Control', 'no-store'); @@ -16,7 +19,7 @@ export const errorHandler: ErrorHandler = (err, c) => { return c.json({ error: 'The server was unable to respond in a timely manner' }, 500); } - console.error(err); + logi({ level: 'error', ns: 'ditto.http', message: 'Unhandled error', error: errorJson(err) }); return c.json({ error: 'Something went wrong' }, 500); }; diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index fca8c0a6..ad5c00dc 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -1,16 +1,16 @@ +import { logi } from '@soapbox/logi'; + import { AppMiddleware } from '@/app.ts'; -import { Stickynotes } from '@soapbox/stickynotes'; import { Storages } from '@/storages.ts'; import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; +import { errorJson } from '@/utils/log.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { renderMetadata } from '@/views/meta.ts'; import { getAuthor, getEvent } from '@/queries.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; -const console = new Stickynotes('ditto:frontend'); - /** Placeholder to find & replace with metadata. */ const META_PLACEHOLDER = '' as const; @@ -27,7 +27,7 @@ export const frontendController: AppMiddleware = async (c) => { const meta = renderMetadata(c.req.url, entities); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { - console.log(`Error building meta tags: ${e}`); + logi({ level: 'error', ns: 'ditto.frontend', message: 'Error building meta tags', error: errorJson(e) }); return c.html(content); } } diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 6b1c2fbc..4c7feea8 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,4 +1,4 @@ -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import { NKinds, NostrClientCLOSE, @@ -17,11 +17,12 @@ import { relayConnectionsGauge, relayEventsCounter, relayMessagesCounter } from import * as pipeline from '@/pipeline.ts'; import { RelayError } from '@/RelayError.ts'; import { Storages } from '@/storages.ts'; -import { Time } from '@/utils/time.ts'; +import { errorJson } from '@/utils/log.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { MemoryRateLimiter } from '@/utils/ratelimiter/MemoryRateLimiter.ts'; import { MultiRateLimiter } from '@/utils/ratelimiter/MultiRateLimiter.ts'; import { RateLimiter } from '@/utils/ratelimiter/types.ts'; +import { Time } from '@/utils/time.ts'; /** Limit of initial events returned for a subscription. */ const FILTER_LIMIT = 100; @@ -44,8 +45,6 @@ const limiters = { /** Connections for metrics purposes. */ const connections = new Set(); -const console = new Stickynotes('ditto:relay'); - /** Set up the Websocket connection. */ function connectStream(socket: WebSocket, ip: string | undefined) { const controllers = new Map(); @@ -169,7 +168,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { send(['OK', event.id, false, e.message]); } else { send(['OK', event.id, false, 'error: something went wrong']); - console.error(e); + logi({ level: 'error', ns: 'ditto.relay', message: 'Error in relay', error: errorJson(e) }); } } } diff --git a/src/db/DittoDB.ts b/src/db/DittoDB.ts index 923a109d..ddc0b86d 100644 --- a/src/db/DittoDB.ts +++ b/src/db/DittoDB.ts @@ -1,12 +1,15 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { logi } from '@soapbox/logi'; +import { JsonValue } from '@std/json'; import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; import { DittoPglite } from '@/db/adapters/DittoPglite.ts'; import { DittoPostgres } from '@/db/adapters/DittoPostgres.ts'; import { DittoDatabase, DittoDatabaseOpts } from '@/db/DittoDatabase.ts'; import { DittoTables } from '@/db/DittoTables.ts'; +import { errorJson } from '@/utils/log.ts'; export class DittoDB { /** Open a new database connection. */ @@ -36,20 +39,30 @@ export class DittoDB { }), }); - console.warn('Running migrations...'); + logi({ level: 'info', ns: 'ditto.db.migration', message: 'Running migrations...', state: 'started' }); const { results, error } = await migrator.migrateToLatest(); if (error) { - console.error(error); + logi({ + level: 'fatal', + ns: 'ditto.db.migration', + message: 'Migration failed.', + state: 'failed', + results: results as unknown as JsonValue, + error: errorJson(error), + }); Deno.exit(1); } else { if (!results?.length) { - console.warn('Everything up-to-date.'); + logi({ level: 'info', ns: 'ditto.db.migration', message: 'Everything up-to-date.', state: 'skipped' }); } else { - console.warn('Migrations finished!'); - for (const { migrationName, status } of results!) { - console.warn(` - ${migrationName}: ${status}`); - } + logi({ + level: 'info', + ns: 'ditto.db.migration', + message: 'Migrations finished!', + state: 'migrated', + results: results as unknown as JsonValue, + }); } } } diff --git a/src/db/KyselyLogger.ts b/src/db/KyselyLogger.ts index d101dd48..dea1725a 100644 --- a/src/db/KyselyLogger.ts +++ b/src/db/KyselyLogger.ts @@ -2,6 +2,7 @@ import { logi } from '@soapbox/logi'; import { Logger } from 'kysely'; import { dbQueriesCounter, dbQueryDurationHistogram } from '@/metrics.ts'; +import { errorJson } from '@/utils/log.ts'; /** Log the SQL for queries. */ export const KyselyLogger: Logger = (event) => { @@ -27,10 +28,6 @@ export const KyselyLogger: Logger = (event) => { } if (event.level === 'error') { - const error = event.error instanceof Error - ? { name: event.error.name, message: event.error.message } - : { name: 'unknown', message: 'Unknown error' }; - - logi({ level: 'error', ns: 'ditto.sql', sql, parameters, error, duration }); + logi({ level: 'error', ns: 'ditto.sql', sql, parameters, error: errorJson(event.error), duration }); } }; diff --git a/src/firehose.ts b/src/firehose.ts index 0dd88ba2..fca2e079 100644 --- a/src/firehose.ts +++ b/src/firehose.ts @@ -1,5 +1,5 @@ import { Semaphore } from '@lambdalisue/async'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import { Conf } from '@/config.ts'; import { firehoseEventsCounter } from '@/metrics.ts'; @@ -8,7 +8,6 @@ import { nostrNow } from '@/utils.ts'; import * as pipeline from '@/pipeline.ts'; -const console = new Stickynotes('ditto:firehose'); const sem = new Semaphore(Conf.firehoseConcurrency); /** @@ -22,14 +21,14 @@ export async function startFirehose(): Promise { for await (const msg of store.req([{ kinds: Conf.firehoseKinds, limit: 0, since: nostrNow() }])) { if (msg[0] === 'EVENT') { const event = msg[2]; - console.debug(`NostrEvent<${event.kind}> ${event.id}`); + logi({ level: 'debug', ns: 'ditto.event', source: 'firehose', id: event.id, kind: event.kind }); firehoseEventsCounter.inc({ kind: event.kind }); sem.lock(async () => { try { await pipeline.handleEvent(event, { source: 'firehose', signal: AbortSignal.timeout(5000) }); - } catch (e) { - console.warn(e); + } catch { + // Ignore } }); } diff --git a/src/middleware/logiMiddleware.ts b/src/middleware/logiMiddleware.ts new file mode 100644 index 00000000..0c3dedda --- /dev/null +++ b/src/middleware/logiMiddleware.ts @@ -0,0 +1,18 @@ +import { MiddlewareHandler } from '@hono/hono'; +import { logi } from '@soapbox/logi'; + +export const logiMiddleware: MiddlewareHandler = async (c, next) => { + const { method } = c.req; + const { pathname } = new URL(c.req.url); + + logi({ level: 'info', ns: 'ditto.http.request', method, pathname }); + + const start = new Date(); + + await next(); + + const end = new Date(); + const delta = (end.getTime() - start.getTime()) / 1000; + + logi({ level: 'info', ns: 'ditto.http.response', method, pathname, status: c.res.status, delta }); +}; diff --git a/src/notify.ts b/src/notify.ts index cda22718..b1ee3517 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -1,13 +1,12 @@ import { Semaphore } from '@lambdalisue/async'; -import { Stickynotes } from '@soapbox/stickynotes'; import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; import { Conf } from '@/config.ts'; import * as pipeline from '@/pipeline.ts'; import { Storages } from '@/storages.ts'; +import { logi } from '@soapbox/logi'; const sem = new Semaphore(1); -const console = new Stickynotes('ditto:notify'); export async function startNotify(): Promise { const { listen } = await Storages.database(); @@ -15,10 +14,12 @@ export async function startNotify(): Promise { listen('nostr_event', (id) => { if (pipelineEncounters.has(id)) { - console.debug(`Skip event ${id} because it was already in the pipeline`); + logi({ level: 'debug', ns: 'ditto.notify', id, skipped: true }); return; } + logi({ level: 'debug', ns: 'ditto.notify', id, skipped: false }); + sem.lock(async () => { try { const signal = AbortSignal.timeout(Conf.db.timeouts.default); @@ -26,10 +27,11 @@ export async function startNotify(): Promise { const [event] = await store.query([{ ids: [id], limit: 1 }], { signal }); if (event) { + logi({ level: 'debug', ns: 'ditto.event', source: 'notify', id: event.id, kind: event.kind }); await pipeline.handleEvent(event, { source: 'notify', signal }); } - } catch (e) { - console.warn(e); + } catch { + // Ignore } }); }); diff --git a/src/pipeline.ts b/src/pipeline.ts index 688fe3bb..d63c112d 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,8 +1,9 @@ import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import { Kysely, sql } from 'kysely'; import { z } from 'zod'; +import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; import { Conf } from '@/config.ts'; import { DittoTables } from '@/db/DittoTables.ts'; import { DittoPush } from '@/DittoPush.ts'; @@ -15,6 +16,7 @@ import { Storages } from '@/storages.ts'; import { eventAge, parseNip05, Time } from '@/utils.ts'; import { getAmount } from '@/utils/bolt11.ts'; import { detectLanguage } from '@/utils/language.ts'; +import { errorJson } from '@/utils/log.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { updateStats } from '@/utils/stats.ts'; @@ -22,9 +24,6 @@ import { getTagSet } from '@/utils/tags.ts'; import { renderWebPushNotification } from '@/views/mastodon/push.ts'; import { policyWorker } from '@/workers/policy.ts'; import { verifyEventWorker } from '@/workers/verify.ts'; -import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; - -const console = new Stickynotes('ditto:pipeline'); interface PipelineOpts { signal: AbortSignal; @@ -69,7 +68,7 @@ async function handleEvent(event: DittoEvent, opts: PipelineOpts): Promise pipelineEncounters.set(event.id, true); // Log the event. - console.info(`NostrEvent<${event.kind}> ${event.id}`); + logi({ level: 'debug', ns: 'ditto.event', source: 'pipeline', id: event.id, kind: event.kind }); pipelineEventsCounter.inc({ kind: event.kind }); // NIP-46 events get special treatment. @@ -135,18 +134,17 @@ async function handleEvent(event: DittoEvent, opts: PipelineOpts): Promise } async function policyFilter(event: NostrEvent, signal: AbortSignal): Promise { - const console = new Stickynotes('ditto:policy'); - try { const result = await policyWorker.call(event, signal); - policyEventsCounter.inc({ ok: String(result[2]) }); - console.debug(JSON.stringify(result)); + const [, , ok, reason] = result; + logi({ level: 'debug', ns: 'ditto.policy', id: event.id, kind: event.kind, ok, reason }); + policyEventsCounter.inc({ ok: String(ok) }); RelayError.assert(result); } catch (e) { if (e instanceof RelayError) { throw e; } else { - console.error(e); + logi({ level: 'error', ns: 'ditto.policy', id: event.id, kind: event.kind, error: errorJson(e) }); throw new RelayError('blocked', 'policy error'); } } diff --git a/src/queries.ts b/src/queries.ts index 36066ce2..f60d3daa 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,5 +1,4 @@ import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; @@ -9,8 +8,6 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { fallbackAuthor } from '@/utils.ts'; import { findReplyTag, getTagSet } from '@/utils/tags.ts'; -const debug = Debug('ditto:queries'); - interface GetEventOpts { /** Signal to abort the request. */ signal?: AbortSignal; @@ -20,12 +17,14 @@ interface GetEventOpts { relations?: DittoRelation[]; } -/** Get a Nostr event by its ID. */ +/** + * Get a Nostr event by its ID. + * @deprecated Use `store.query` directly. + */ const getEvent = async ( id: string, opts: GetEventOpts = {}, ): Promise => { - debug(`getEvent: ${id}`); const store = await Storages.db(); const { kind, signal = AbortSignal.timeout(1000) } = opts; diff --git a/src/sentry.ts b/src/sentry.ts index 84b662e2..29a4288a 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -1,12 +1,15 @@ import * as Sentry from '@sentry/deno'; +import { logi } from '@soapbox/logi'; import { Conf } from '@/config.ts'; // Sentry if (Conf.sentryDsn) { - console.log('Sentry enabled'); + logi({ level: 'info', ns: 'ditto.sentry', message: 'Sentry enabled.', enabled: true }); Sentry.init({ dsn: Conf.sentryDsn, tracesSampleRate: 1.0, }); +} else { + logi({ level: 'info', ns: 'ditto.sentry', message: 'Sentry not configured. Skipping.', enabled: false }); } diff --git a/src/server.ts b/src/server.ts index 4825e99d..513e55bd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,14 @@ +import { logi } from '@soapbox/logi'; + import '@/precheck.ts'; import '@/sentry.ts'; import '@/nostr-wasm.ts'; import app from '@/app.ts'; import { Conf } from '@/config.ts'; -Deno.serve({ port: Conf.port }, app.fetch); +Deno.serve({ + port: Conf.port, + onListen({ hostname, port }): void { + logi({ level: 'info', ns: 'ditto.server', message: `Listening on http://${hostname}:${port}`, hostname, port }); + }, +}, app.fetch); diff --git a/src/storages.ts b/src/storages.ts index 867c7939..8812f298 100644 --- a/src/storages.ts +++ b/src/storages.ts @@ -1,4 +1,6 @@ // deno-lint-ignore-file require-await +import { logi } from '@soapbox/logi'; + import { Conf } from '@/config.ts'; import { DittoDatabase } from '@/db/DittoDatabase.ts'; import { DittoDB } from '@/db/DittoDB.ts'; @@ -89,7 +91,12 @@ export class Storages { return acc; }, []); - console.log(`pool: connecting to ${activeRelays.length} relays.`); + logi({ + level: 'info', + ns: 'ditto.pool', + message: `connecting to ${activeRelays.length} relays`, + relays: activeRelays, + }); return new NPool({ open(url) { diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index aa2a0106..6397a23d 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -3,7 +3,8 @@ import { LanguageCode } from 'iso-639-1'; import { NPostgres, NPostgresSchema } from '@nostrify/db'; import { NIP50, NKinds, NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; +import { JsonValue } from '@std/json'; import { Kysely, SelectQueryBuilder } from 'kysely'; import { nip27 } from 'nostr-tools'; @@ -36,8 +37,6 @@ interface EventsDBOpts { /** SQL database storage adapter for Nostr events. */ class EventsDB extends NPostgres { - private console = new Stickynotes('ditto:db:events'); - /** Conditions for when to index certain tags. */ static tagConditions: Record = { 'a': ({ count }) => count < 15, @@ -65,7 +64,7 @@ class EventsDB extends NPostgres { /** Insert an event (and its tags) into the database. */ override async event(event: NostrEvent, opts: { signal?: AbortSignal; timeout?: number } = {}): Promise { event = purifyEvent(event); - this.console.debug('EVENT', JSON.stringify(event)); + logi({ level: 'debug', ns: 'ditto.event', source: 'db', id: event.id, kind: event.kind }); dbEventsCounter.inc({ kind: event.kind }); if (await this.isDeletedAdmin(event)) { @@ -198,7 +197,7 @@ class EventsDB extends NPostgres { if (opts.signal?.aborted) return Promise.resolve([]); - this.console.debug('REQ', JSON.stringify(filters)); + logi({ level: 'debug', ns: 'ditto.req', source: 'db', filters: filters as JsonValue }); return super.query(filters, { ...opts, timeout: opts.timeout ?? this.opts.timeout }); } @@ -228,7 +227,7 @@ class EventsDB extends NPostgres { /** Delete events based on filters from the database. */ override async remove(filters: NostrFilter[], opts: { signal?: AbortSignal; timeout?: number } = {}): Promise { - this.console.debug('DELETE', JSON.stringify(filters)); + logi({ level: 'debug', ns: 'ditto.remove', source: 'db', filters: filters as JsonValue }); return super.remove(filters, { ...opts, timeout: opts.timeout ?? this.opts.timeout }); } @@ -239,7 +238,7 @@ class EventsDB extends NPostgres { ): Promise<{ count: number; approximate: any }> { if (opts.signal?.aborted) return Promise.reject(abortError()); - this.console.debug('COUNT', JSON.stringify(filters)); + logi({ level: 'debug', ns: 'ditto.count', source: 'db', filters: filters as JsonValue }); return super.count(filters, { ...opts, timeout: opts.timeout ?? this.opts.timeout }); } diff --git a/src/storages/search-store.ts b/src/storages/search-store.ts index 4951c722..44dc1519 100644 --- a/src/storages/search-store.ts +++ b/src/storages/search-store.ts @@ -1,5 +1,6 @@ import { NostrEvent, NostrFilter, NRelay1, NStore } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; +import { logi } from '@soapbox/logi'; +import { JsonValue } from '@std/json'; import { normalizeFilters } from '@/filter.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; @@ -13,8 +14,6 @@ interface SearchStoreOpts { } class SearchStore implements NStore { - #debug = Debug('ditto:storages:search'); - #fallback: NStore; #hydrator: NStore; #relay: NRelay1 | undefined; @@ -38,11 +37,11 @@ class SearchStore implements NStore { if (opts?.signal?.aborted) return Promise.reject(abortError()); if (!filters.length) return Promise.resolve([]); - this.#debug('REQ', JSON.stringify(filters)); + logi({ level: 'debug', ns: 'ditto.req', source: 'search', filters: filters as JsonValue }); const query = filters[0]?.search; if (this.#relay && this.#relay.socket.readyState === WebSocket.OPEN) { - this.#debug(`Searching for "${query}" at ${this.#relay.socket.url}...`); + logi({ level: 'debug', ns: 'ditto.search', query, source: 'relay', relay: this.#relay.socket.url }); const events = await this.#relay.query(filters, opts); @@ -52,7 +51,7 @@ class SearchStore implements NStore { signal: opts?.signal, }); } else { - this.#debug(`Searching for "${query}" locally...`); + logi({ level: 'debug', ns: 'ditto.search', query, source: 'db' }); return this.#fallback.query(filters, opts); } } diff --git a/src/trends.ts b/src/trends.ts index cbe85c14..1531597a 100644 --- a/src/trends.ts +++ b/src/trends.ts @@ -1,5 +1,5 @@ import { NostrFilter } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import { Kysely, sql } from 'kysely'; import { Conf } from '@/config.ts'; @@ -7,10 +7,9 @@ import { DittoTables } from '@/db/DittoTables.ts'; import { handleEvent } from '@/pipeline.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; +import { errorJson } from '@/utils/log.ts'; import { Time } from '@/utils/time.ts'; -const console = new Stickynotes('ditto:trends'); - /** Get trending tag values for a given tag in the given time frame. */ export async function getTrendingTagValues( /** Kysely instance to execute queries on. */ @@ -75,7 +74,9 @@ export async function updateTrendingTags( aliases?: string[], values?: string[], ) { - console.info(`Updating trending ${l}...`); + const params = { l, tagName, kinds, limit, extra, aliases, values }; + logi({ level: 'info', ns: 'ditto.trends', message: 'Updating trending', ...params }); + const kysely = await Storages.kysely(); const signal = AbortSignal.timeout(1000); @@ -92,9 +93,10 @@ export async function updateTrendingTags( limit, }, values); - console.log(trends); - if (!trends.length) { - console.info(`No trending ${l} found. Skipping.`); + if (trends.length) { + logi({ level: 'info', ns: 'ditto.trends', message: 'Trends found', trends, ...params }); + } else { + logi({ level: 'info', ns: 'ditto.trends', message: 'No trends found. Skipping.', ...params }); return; } @@ -112,9 +114,9 @@ export async function updateTrendingTags( }); await handleEvent(label, { source: 'internal', signal }); - console.info(`Trending ${l} updated.`); + logi({ level: 'info', ns: 'ditto.trends', message: 'Trends updated', ...params }); } catch (e) { - console.error(`Error updating trending ${l}: ${e instanceof Error ? e.message : e}`); + logi({ level: 'error', ns: 'ditto.trends', message: 'Error updating trends', ...params, error: errorJson(e) }); } } diff --git a/src/utils/api.ts b/src/utils/api.ts index 9e7125c6..89cb608b 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,7 +1,7 @@ import { type Context } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; +import { logi } from '@soapbox/logi'; import { EventTemplate } from 'nostr-tools'; import * as TypeFest from 'type-fest'; @@ -15,8 +15,6 @@ import { nostrNow } from '@/utils.ts'; import { parseFormData } from '@/utils/formdata.ts'; import { purifyEvent } from '@/utils/purify.ts'; -const debug = Debug('ditto:api'); - /** EventTemplate with defaults. */ type EventStub = TypeFest.SetOptional; @@ -159,7 +157,7 @@ async function updateNames(k: number, d: string, n: Record, c: /** Push the event through the pipeline, rethrowing any RelayError. */ async function publishEvent(event: NostrEvent, c: AppContext): Promise { - debug('EVENT', event); + logi({ level: 'info', ns: 'ditto.event', source: 'api', id: event.id, kind: event.kind }); try { await pipeline.handleEvent(event, { source: 'api', signal: c.req.raw.signal }); const client = await Storages.client(); diff --git a/src/utils/favicon.ts b/src/utils/favicon.ts index dfe82d1b..9833de1c 100644 --- a/src/utils/favicon.ts +++ b/src/utils/favicon.ts @@ -1,5 +1,5 @@ import { DOMParser } from '@b-fuze/deno-dom'; -import Debug from '@soapbox/stickynotes/debug'; +import { logi } from '@soapbox/logi'; import tldts from 'tldts'; import { Conf } from '@/config.ts'; @@ -7,18 +7,16 @@ import { cachedFaviconsSizeGauge } from '@/metrics.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { fetchWorker } from '@/workers/fetch.ts'; -const debug = Debug('ditto:favicon'); - const faviconCache = new SimpleLRU( - async (key, { signal }) => { - debug(`Fetching favicon ${key}`); - const tld = tldts.parse(key); + async (domain, { signal }) => { + logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'started' }); + const tld = tldts.parse(domain); if (!tld.isIcann || tld.isIp || tld.isPrivate) { - throw new Error(`Invalid favicon domain: ${key}`); + throw new Error(`Invalid favicon domain: ${domain}`); } - const rootUrl = new URL('/', `https://${key}/`); + const rootUrl = new URL('/', `https://${domain}/`); const response = await fetchWorker(rootUrl, { signal }); const html = await response.text(); @@ -28,15 +26,28 @@ const faviconCache = new SimpleLRU( if (link) { const href = link.getAttribute('href'); if (href) { + let url: URL | undefined; + try { - return new URL(href); + url = new URL(href); } catch { - return new URL(href, rootUrl); + try { + url = new URL(href, rootUrl); + } catch { + // fall through + } + } + + if (url) { + logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'found', url }); + return url; } } } - throw new Error(`Favicon not found: ${key}`); + logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'failed' }); + + throw new Error(`Favicon not found: ${domain}`); }, { ...Conf.caches.favicon, gauge: cachedFaviconsSizeGauge }, ); diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index 1dd99769..c70f5751 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -1,23 +1,23 @@ +import { NostrEvent } from '@nostrify/nostrify'; import { LNURL, LNURLDetails } from '@nostrify/nostrify/ln'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; +import { JsonValue } from '@std/json'; import { cachedLnurlsSizeGauge } from '@/metrics.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; +import { errorJson } from '@/utils/log.ts'; import { Time } from '@/utils/time.ts'; import { fetchWorker } from '@/workers/fetch.ts'; -import { NostrEvent } from '@nostrify/nostrify'; - -const console = new Stickynotes('ditto:lnurl'); const lnurlCache = new SimpleLRU( async (lnurl, { signal }) => { - console.debug(`Lookup ${lnurl}`); + logi({ level: 'info', ns: 'ditto.lnurl', lnurl, state: 'started' }); try { - const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal }); - console.debug(`Found: ${lnurl}`); - return result; + const details = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal }); + logi({ level: 'info', ns: 'ditto.lnurl', lnurl, state: 'found', details: details as unknown as JsonValue }); + return details; } catch (e) { - console.debug(`Not found: ${lnurl}`); + logi({ level: 'info', ns: 'ditto.lnurl', lnurl, state: 'failed', error: errorJson(e) }); throw e; } }, diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 00000000..4a96e39a --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,10 @@ +import { JsonValue } from '@std/json'; + +/** Serialize an error into JSON for JSON logging. */ +export function errorJson(error: unknown): JsonValue { + if (error instanceof Error) { + return { name: error.name, message: error.message, stack: error.stack }; + } + + return { name: 'unknown', message: String(error) }; +} diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index 48c6ba81..19b5b148 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -6,7 +6,6 @@ import tldts from 'tldts'; import { getAuthor } from '@/queries.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; -import { Stickynotes } from '@soapbox/stickynotes'; /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( @@ -22,8 +21,6 @@ export async function lookupAccount( /** Resolve a bech32 or NIP-05 identifier to a pubkey. */ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise { - const console = new Stickynotes('ditto:lookup'); - if (n.bech32().safeParse(value).success) { return bech32ToPubkey(value); } @@ -32,7 +29,6 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise const { pubkey } = await nip05Cache.fetch(value, { signal }); return pubkey; } catch (e) { - console.debug(e); return; } } diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts index cd763d92..65f425a3 100644 --- a/src/utils/nip05.ts +++ b/src/utils/nip05.ts @@ -1,45 +1,45 @@ import { nip19 } from 'nostr-tools'; import { NIP05, NStore } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; +import { logi } from '@soapbox/logi'; import tldts from 'tldts'; import { Conf } from '@/config.ts'; import { cachedNip05sSizeGauge } from '@/metrics.ts'; import { Storages } from '@/storages.ts'; +import { errorJson } from '@/utils/log.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { Nip05, parseNip05 } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; -const debug = Debug('ditto:nip05'); - const nip05Cache = new SimpleLRU( - async (key, { signal }) => { - debug(`Lookup ${key}`); - const tld = tldts.parse(key); + async (nip05, { signal }) => { + const tld = tldts.parse(nip05); if (!tld.isIcann || tld.isIp || tld.isPrivate) { - throw new Error(`Invalid NIP-05: ${key}`); + throw new Error(`Invalid NIP-05: ${nip05}`); } - const [name, domain] = key.split('@'); + const [name, domain] = nip05.split('@'); + + logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'started' }); try { if (domain === Conf.url.host) { const store = await Storages.db(); const pointer = await localNip05Lookup(store, name); if (pointer) { - debug(`Found: ${key} is ${pointer.pubkey}`); + logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', pubkey: pointer.pubkey }); return pointer; } else { - throw new Error(`Not found: ${key}`); + throw new Error(`Not found: ${nip05}`); } } else { - const result = await NIP05.lookup(key, { fetch: fetchWorker, signal }); - debug(`Found: ${key} is ${result.pubkey}`); + const result = await NIP05.lookup(nip05, { fetch: fetchWorker, signal }); + logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', pubkey: result.pubkey }); return result; } } catch (e) { - debug(`Not found: ${key}`); + logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'failed', error: errorJson(e) }); throw e; } }, diff --git a/src/utils/unfurl.ts b/src/utils/unfurl.ts index b5f5c4eb..731b586e 100644 --- a/src/utils/unfurl.ts +++ b/src/utils/unfurl.ts @@ -1,17 +1,15 @@ import TTLCache from '@isaacs/ttlcache'; -import Debug from '@soapbox/stickynotes/debug'; +import { logi } from '@soapbox/logi'; import DOMPurify from 'isomorphic-dompurify'; import { unfurl } from 'unfurl.js'; import { Conf } from '@/config.ts'; import { PreviewCard } from '@/entities/PreviewCard.ts'; import { cachedLinkPreviewSizeGauge } from '@/metrics.ts'; +import { errorJson } from '@/utils/log.ts'; import { fetchWorker } from '@/workers/fetch.ts'; -const debug = Debug('ditto:unfurl'); - async function unfurlCard(url: string, signal: AbortSignal): Promise { - debug(`Unfurling ${url}...`); try { const result = await unfurl(url, { fetch: (url) => @@ -26,7 +24,7 @@ async function unfurlCard(url: string, signal: AbortSignal): Promise import { safeFetch } from '@soapbox/safe-fetch'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import * as Comlink from 'comlink'; import '@/workers/handlers/abortsignal.ts'; import '@/sentry.ts'; -const console = new Stickynotes('ditto:fetch.worker'); - export const FetchWorker = { async fetch( url: string, init: Omit, signal: AbortSignal | null | undefined, ): Promise<[BodyInit, ResponseInit]> { - console.debug(init.method, url); + logi({ level: 'debug', ns: 'ditto.fetch', method: init.method ?? 'GET', url }); + const response = await safeFetch(url, { ...init, signal }); + return [ await response.arrayBuffer(), { diff --git a/src/workers/policy.ts b/src/workers/policy.ts index 4124feb9..92a9dd76 100644 --- a/src/workers/policy.ts +++ b/src/workers/policy.ts @@ -1,5 +1,5 @@ import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; +import { logi } from '@soapbox/logi'; import * as Comlink from 'comlink'; import { Conf } from '@/config.ts'; @@ -7,8 +7,6 @@ import type { CustomPolicy } from '@/workers/policy.worker.ts'; import '@/workers/handlers/abortsignal.ts'; -const console = new Stickynotes('ditto:policy'); - class PolicyWorker implements NPolicy { private worker: Comlink.Remote; private ready: Promise; @@ -55,16 +53,34 @@ class PolicyWorker implements NPolicy { pubkey: Conf.pubkey, }); - console.warn(`Using custom policy: ${Conf.policy}`); + logi({ + level: 'info', + ns: 'ditto.system.policy', + message: 'Using custom policy', + path: Conf.policy, + enabled: true, + }); } catch (e) { if (e instanceof Error && e.message.includes('Module not found')) { - console.warn('Custom policy not found '); + logi({ + level: 'info', + ns: 'ditto.system.policy', + message: 'Custom policy not found ', + path: null, + enabled: false, + }); this.enabled = false; return; } if (e instanceof Error && e.message.includes('PGlite is not supported in worker threads')) { - console.warn('Custom policies are not supported with PGlite. The policy is disabled.'); + logi({ + level: 'warn', + ns: 'ditto.system.policy', + message: 'Custom policies are not supported with PGlite. The policy is disabled.', + path: Conf.policy, + enabled: false, + }); this.enabled = false; return; } From d23990e709dafe8b88a993b8798214dc9dafa5c0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 18:13:29 -0600 Subject: [PATCH 23/47] Remove unused variable --- src/utils/lookup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index 19b5b148..9afd8a08 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -28,7 +28,7 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise try { const { pubkey } = await nip05Cache.fetch(value, { signal }); return pubkey; - } catch (e) { + } catch { return; } } From 78cde6dcb247b53ca97fa76f8584ef12e199a7a8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 18:14:12 -0600 Subject: [PATCH 24/47] Fix import order in api/admin --- src/controllers/api/admin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index 73af90dd..146c2869 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -1,4 +1,5 @@ import { NostrFilter } from '@nostrify/nostrify'; +import { logi } from '@soapbox/logi'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; @@ -9,7 +10,6 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; -import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; const adminAccountQuerySchema = z.object({ From 5ea33f6817fa1aed35d33019ae0848596b17984b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 19:26:26 -0600 Subject: [PATCH 25/47] KyselyLogger: improve parameter serialization --- src/db/KyselyLogger.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/db/KyselyLogger.ts b/src/db/KyselyLogger.ts index dea1725a..0c436b3e 100644 --- a/src/db/KyselyLogger.ts +++ b/src/db/KyselyLogger.ts @@ -3,6 +3,7 @@ import { Logger } from 'kysely'; import { dbQueriesCounter, dbQueryDurationHistogram } from '@/metrics.ts'; import { errorJson } from '@/utils/log.ts'; +import { JsonValue } from '@std/json'; /** Log the SQL for queries. */ export const KyselyLogger: Logger = (event) => { @@ -14,14 +15,7 @@ export const KyselyLogger: Logger = (event) => { dbQueriesCounter.inc(); dbQueryDurationHistogram.observe(duration); - /** Parameters serialized to JSON. */ - const parameters = query.parameters.map((parameter) => { - try { - return JSON.stringify(parameter); - } catch { - return String(parameter); - } - }); + const parameters = query.parameters.map(serializeParameter); if (event.level === 'query') { logi({ level: 'debug', ns: 'ditto.sql', sql, parameters, duration }); @@ -31,3 +25,21 @@ export const KyselyLogger: Logger = (event) => { logi({ level: 'error', ns: 'ditto.sql', sql, parameters, error: errorJson(event.error), duration }); } }; + +/** Serialize parameter to JSON. */ +function serializeParameter(parameter: unknown): JsonValue { + if (Array.isArray(parameter)) { + return parameter.map(serializeParameter); + } + if ( + typeof parameter === 'string' || typeof parameter === 'number' || typeof parameter === 'boolean' || + parameter === null + ) { + return parameter; + } + try { + return JSON.stringify(parameter); + } catch { + return String(parameter); + } +} From fd553d98e248e200b680dbba521d98472310dd8d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 19:26:52 -0600 Subject: [PATCH 26/47] KyselyLogger: fix import order --- src/db/KyselyLogger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/KyselyLogger.ts b/src/db/KyselyLogger.ts index 0c436b3e..0f10834c 100644 --- a/src/db/KyselyLogger.ts +++ b/src/db/KyselyLogger.ts @@ -1,9 +1,9 @@ import { logi } from '@soapbox/logi'; +import { JsonValue } from '@std/json'; import { Logger } from 'kysely'; import { dbQueriesCounter, dbQueryDurationHistogram } from '@/metrics.ts'; import { errorJson } from '@/utils/log.ts'; -import { JsonValue } from '@std/json'; /** Log the SQL for queries. */ export const KyselyLogger: Logger = (event) => { From 7a2a6e00c19a2630d554553349003e49f19429fc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jan 2025 21:38:11 -0600 Subject: [PATCH 27/47] Upgrade Logi --- deno.json | 2 +- deno.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deno.json b/deno.json index d4864fe6..40668402 100644 --- a/deno.json +++ b/deno.json @@ -52,7 +52,7 @@ "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", - "@soapbox/logi": "jsr:@soapbox/logi@^0.1.3", + "@soapbox/logi": "jsr:@soapbox/logi@^0.2.1", "@soapbox/safe-fetch": "jsr:@soapbox/safe-fetch@^2.0.0", "@std/assert": "jsr:@std/assert@^0.225.1", "@std/cli": "jsr:@std/cli@^0.223.0", diff --git a/deno.lock b/deno.lock index f6ac0798..877a90de 100644 --- a/deno.lock +++ b/deno.lock @@ -49,7 +49,7 @@ "jsr:@nostrify/types@0.36": "0.36.0", "jsr:@nostrify/types@~0.30.1": "0.30.1", "jsr:@soapbox/kysely-pglite@1": "1.0.0", - "jsr:@soapbox/logi@~0.1.3": "0.1.3", + "jsr:@soapbox/logi@~0.2.1": "0.2.1", "jsr:@soapbox/safe-fetch@2": "2.0.0", "jsr:@std/assert@0.223": "0.223.0", "jsr:@std/assert@0.224": "0.224.0", @@ -526,8 +526,8 @@ "npm:kysely@~0.27.4" ] }, - "@soapbox/logi@0.1.3": { - "integrity": "1b974f26550d2eba08171f2374ae39876b55a5e7c2780a08b8d04cda86f6f5f2" + "@soapbox/logi@0.2.1": { + "integrity": "763d624c45adb74ec55e24911d14933d1883606c14701e171be7bfb76f9029be" }, "@soapbox/safe-fetch@2.0.0": { "integrity": "f451d686501c76a0faa058fe9d2073676282a8a42c3b93c59159eb9191f11b5f", @@ -2353,7 +2353,7 @@ "jsr:@nostrify/policies@~0.36.1", "jsr:@nostrify/types@0.36", "jsr:@soapbox/kysely-pglite@1", - "jsr:@soapbox/logi@~0.1.3", + "jsr:@soapbox/logi@~0.2.1", "jsr:@soapbox/safe-fetch@2", "jsr:@std/assert@~0.225.1", "jsr:@std/cli@0.223", From 8deea54ec84889aa701ba31989a72b8981062856 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jan 2025 11:40:16 -0600 Subject: [PATCH 28/47] Add IP_WHITELIST variable to bypass rate limiting --- src/config.ts | 4 ++++ src/controllers/nostr/relay.ts | 7 ++++++- src/middleware/rateLimitMiddleware.ts | 7 ++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index b82ef5ea..b65e0cfd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,10 @@ class Conf { static get port(): number { return parseInt(Deno.env.get('PORT') || '4036'); } + /** IP addresses not affected by rate limiting. */ + static get ipWhitelist(): string[] { + return Deno.env.get('IP_WHITELIST')?.split(',') || []; + } /** Relay URL to the Ditto server's relay. */ static get relay(): `wss://${string}` | `ws://${string}` { const { protocol, host } = Conf.url; diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 4c7feea8..5412cdc1 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -210,7 +210,12 @@ const relayController: AppController = (c, next) => { return c.text('Please use a Nostr client to connect.', 400); } - const ip = c.req.header('x-real-ip'); + let ip = c.req.header('x-real-ip'); + + if (ip && Conf.ipWhitelist.includes(ip)) { + ip = undefined; + } + if (ip) { const remaining = Object .values(limiters) diff --git a/src/middleware/rateLimitMiddleware.ts b/src/middleware/rateLimitMiddleware.ts index e7a43328..4d243d2c 100644 --- a/src/middleware/rateLimitMiddleware.ts +++ b/src/middleware/rateLimitMiddleware.ts @@ -1,6 +1,8 @@ import { MiddlewareHandler } from '@hono/hono'; import { rateLimiter } from 'hono-rate-limiter'; +import { Conf } from '@/config.ts'; + /** * Rate limit middleware for Hono, based on [`hono-rate-limiter`](https://github.com/rhinobase/hono-rate-limiter). */ @@ -14,7 +16,10 @@ export function rateLimitMiddleware(limit: number, windowMs: number, includeHead c.header('Cache-Control', 'no-store'); return c.text('Too many requests, please try again later.', 429); }, - skip: (c) => !c.req.header('x-real-ip'), + skip: (c) => { + const ip = c.req.header('x-real-ip'); + return !ip || Conf.ipWhitelist.includes(ip); + }, keyGenerator: (c) => c.req.header('x-real-ip')!, }); } From cce693dc9bdd2aab3a04c71fd987d1eb1343db81 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jan 2025 14:10:06 -0600 Subject: [PATCH 29/47] EventsDB: index only the final `e` and `p` tag of kind 7 events Fixes https://gitlab.com/soapbox-pub/ditto/-/issues/220 --- src/storages/EventsDB.test.ts | 40 +++++++++++++++++++++++++++++++ src/storages/EventsDB.ts | 45 +++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/storages/EventsDB.test.ts b/src/storages/EventsDB.test.ts index 44937e41..8dc09859 100644 --- a/src/storages/EventsDB.test.ts +++ b/src/storages/EventsDB.test.ts @@ -4,6 +4,7 @@ import { generateSecretKey } from 'nostr-tools'; import { RelayError } from '@/RelayError.ts'; import { eventFixture, genEvent } from '@/test.ts'; import { Conf } from '@/config.ts'; +import { EventsDB } from '@/storages/EventsDB.ts'; import { createTestDB } from '@/test.ts'; Deno.test('count filters', async () => { @@ -244,3 +245,42 @@ Deno.test('NPostgres.query with search', async (t) => { assertEquals(await store.query([{ search: "this shouldn't match" }]), []); }); }); + +Deno.test('EventsDB.indexTags indexes only the final `e` and `p` tag of kind 7 events', () => { + const event = { + kind: 7, + id: 'a92549a442d306b32273aa9456ba48e3851a4e6203af3f567543298ab964b35b', + pubkey: 'f288a224a61b7361aa9dc41a90aba8a2dff4544db0bc386728e638b21da1792c', + created_at: 1737908284, + tags: [ + ['e', '2503cea56931fb25914866e12ffc739741539db4d6815220b9974ef0967fe3f9', '', 'root'], + ['p', 'fad5c18326fb26d9019f1b2aa503802f0253494701bf311d7588a1e65cb8046b'], + ['p', '26d6a946675e603f8de4bf6f9cef442037b70c7eee170ff06ed7673fc34c98f1'], + ['p', '04c960497af618ae18f5147b3e5c309ef3d8a6251768a1c0820e02c93768cc3b'], + ['p', '0114bb11dd8eb89bfb40669509b2a5a473d27126e27acae58257f2fd7cd95776'], + ['p', '9fce3aea32b35637838fb45b75be32595742e16bb3e4742cc82bb3d50f9087e6'], + ['p', '26bd32c67232bdf16d05e763ec67d883015eb99fd1269025224c20c6cfdb0158'], + ['p', 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f'], + ['p', 'edcd20558f17d99327d841e4582f9b006331ac4010806efa020ef0d40078e6da'], + ['p', 'bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91'], + ['p', 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce'], + ['p', '3878d95db7b854c3a0d3b2d6b7bf9bf28b36162be64326f5521ba71cf3b45a69'], + ['p', 'ede3866ddfc40aa4e458952c11c67e827e3cbb8a6a4f0a934c009aa2ed2fb477'], + ['p', 'f288a224a61b7361aa9dc41a90aba8a2dff4544db0bc386728e638b21da1792c'], + ['p', '9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7', '', 'mention'], + ['p', '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', '', 'mention'], + ['e', 'e3653ae41ffb510e5fc071555ecfbc94d2fc31e355d61d941e39a97ac6acb15b'], + ['p', '4e088f3087f6a7e7097ce5fe7fd884ec04ddc69ed6cdd37c55e200f7744b1792'], + ], + content: '🤙', + sig: + '44639d039a7f7fb8772fcfa13d134d3cda684ec34b6a777ead589676f9e8d81b08a24234066dcde1aacfbe193224940fba7586e7197c159757d3caf8f2b57e1b', + }; + + const tags = EventsDB.indexTags(event); + + assertEquals(tags, [ + ['e', 'e3653ae41ffb510e5fc071555ecfbc94d2fc31e355d61d941e39a97ac6acb15b'], + ['p', '4e088f3087f6a7e7097ce5fe7fd884ec04ddc69ed6cdd37c55e200f7744b1792'], + ]); +}); diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 6397a23d..4771a7d2 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -17,11 +17,19 @@ import { purifyEvent } from '@/utils/purify.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; /** Function to decide whether or not to index a tag. */ -type TagCondition = ({ event, count, value }: { +type TagCondition = (opts: TagConditionOpts) => boolean; + +/** Options for the tag condition function. */ +interface TagConditionOpts { + /** Nostr event whose tags are being indexed. */ event: NostrEvent; + /** Count of the current tag name so far. Each tag name has a separate counter starting at 0. */ count: number; + /** Overall tag index. */ + index: number; + /** Current vag value. */ value: string; -}) => boolean; +} /** Options for the EventsDB store. */ interface EventsDBOpts { @@ -41,13 +49,13 @@ class EventsDB extends NPostgres { static tagConditions: Record = { 'a': ({ count }) => count < 15, 'd': ({ event, count }) => count === 0 && NKinds.parameterizedReplaceable(event.kind), - 'e': ({ event, count, value }) => ((event.kind === 10003) || count < 15) && isNostrId(value), + 'e': EventsDB.eTagCondition, 'k': ({ count, value }) => count === 0 && Number.isInteger(Number(value)), 'L': ({ event, count }) => event.kind === 1985 || count === 0, 'l': ({ event, count }) => event.kind === 1985 || count === 0, 'n': ({ count, value }) => count < 50 && value.length < 50, 'P': ({ count, value }) => count === 0 && isNostrId(value), - 'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value), + 'p': EventsDB.pTagCondition, 'proxy': ({ count, value }) => count === 0 && value.length < 256, 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), 'r': ({ event, count }) => (event.kind === 1985 ? count < 20 : count < 3), @@ -243,6 +251,28 @@ class EventsDB extends NPostgres { return super.count(filters, { ...opts, timeout: opts.timeout ?? this.opts.timeout }); } + /** Rule for indexing `e` tags. */ + private static eTagCondition({ event, count, value, index }: TagConditionOpts): boolean { + if (!isNostrId(value)) return false; + + if (event.kind === 7) { + return index === event.tags.findLastIndex(([name]) => name === 'e'); + } + + return event.kind === 10003 || count < 15; + } + + /** Rule for indexing `p` tags. */ + private static pTagCondition({ event, count, value, index }: TagConditionOpts): boolean { + if (!isNostrId(value)) return false; + + if (event.kind === 7) { + return index === event.tags.findLastIndex(([name]) => name === 'p'); + } + + return count < 15 || event.kind === 3; + } + /** Return only the tags that should be indexed. */ static override indexTags(event: NostrEvent): string[][] { const tagCounts: Record = {}; @@ -255,19 +285,20 @@ class EventsDB extends NPostgres { tagCounts[name] = getCount(name) + 1; } - function checkCondition(name: string, value: string, condition: TagCondition) { + function checkCondition(name: string, value: string, condition: TagCondition, index: number): boolean { return condition({ event, count: getCount(name), value, + index, }); } - return event.tags.reduce((results, tag) => { + return event.tags.reduce((results, tag, index) => { const [name, value] = tag; const condition = EventsDB.tagConditions[name] as TagCondition | undefined; - if (value && condition && value.length < 200 && checkCondition(name, value, condition)) { + if (value && condition && value.length < 200 && checkCondition(name, value, condition, index)) { results.push(tag); } From c7264d7627016adbf5e707a65817635c99edc3fb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jan 2025 14:22:16 -0600 Subject: [PATCH 30/47] Fix trends test --- src/trends.test.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/trends.test.ts b/src/trends.test.ts index 66cae23b..47b79eb4 100644 --- a/src/trends.test.ts +++ b/src/trends.test.ts @@ -16,9 +16,11 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", asyn const post1uses = numberOfAuthorsWhoLikedPost1 * post1multiplier; for (let i = 0; i < numberOfAuthorsWhoLikedPost1; i++) { const sk = generateSecretKey(); - events.push( - genEvent({ kind: 7, content: '+', tags: Array(post1multiplier).fill([...['e', post1.id]]) }, sk), - ); + for (let j = 0; j < post1multiplier; j++) { + events.push( + genEvent({ kind: 7, content: '+', tags: [['e', post1.id, `${j}`]] }, sk), + ); + } } events.push(post1); @@ -29,9 +31,11 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", asyn const post2uses = numberOfAuthorsWhoLikedPost2 * post2multiplier; for (let i = 0; i < numberOfAuthorsWhoLikedPost2; i++) { const sk = generateSecretKey(); - events.push( - genEvent({ kind: 7, content: '+', tags: Array(post2multiplier).fill([...['e', post2.id]]) }, sk), - ); + for (let j = 0; j < post2multiplier; j++) { + events.push( + genEvent({ kind: 7, content: '+', tags: [['e', post2.id, `${j}`]] }, sk), + ); + } } events.push(post2); @@ -62,9 +66,11 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async ( const post1uses = numberOfAuthorsWhoLikedPost1 * post1multiplier; for (let i = 0; i < numberOfAuthorsWhoLikedPost1; i++) { const sk = generateSecretKey(); - events.push( - genEvent({ kind: 7, content: '+', tags: Array(post1multiplier).fill([...['e', post1.id]]) }, sk), - ); + for (let j = 0; j < post1multiplier; j++) { + events.push( + genEvent({ kind: 7, content: '+', tags: [['e', post1.id, `${j}`]] }, sk), + ); + } } events.push(post1); @@ -74,9 +80,11 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async ( const post2multiplier = 1; for (let i = 0; i < numberOfAuthorsWhoLikedPost2; i++) { const sk = generateSecretKey(); - events.push( - genEvent({ kind: 7, content: '+', tags: Array(post2multiplier).fill([...['e', post2.id]]) }, sk), - ); + for (let j = 0; j < post2multiplier; j++) { + events.push( + genEvent({ kind: 7, content: '+', tags: [['e', post2.id, `${j}`]] }, sk), + ); + } } events.push(post2); From 49735ce1fefffa63f8beef0d9695892f6835f829 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jan 2025 14:57:32 -0600 Subject: [PATCH 31/47] InstanceV2: bump max_media_attachments to 20 --- src/controllers/api/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index 92e517c3..986537bb 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -119,7 +119,7 @@ const instanceV2Controller: AppController = async (c) => { }, statuses: { max_characters: Conf.postCharLimit, - max_media_attachments: 4, + max_media_attachments: 20, characters_reserved_per_url: 23, }, media_attachments: { From 5f99bddb42c1d0e65a30bf96445c7ee8d622d1bd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jan 2025 19:37:45 -0600 Subject: [PATCH 32/47] Add a logi custom handler for serializing non-JSON stuff (fix sql parameter serialization) --- src/db/KyselyLogger.ts | 33 ++++++++++----------------------- src/startup.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/db/KyselyLogger.ts b/src/db/KyselyLogger.ts index 0f10834c..b640f5e5 100644 --- a/src/db/KyselyLogger.ts +++ b/src/db/KyselyLogger.ts @@ -8,38 +8,25 @@ import { errorJson } from '@/utils/log.ts'; /** Log the SQL for queries. */ export const KyselyLogger: Logger = (event) => { const { query, queryDurationMillis } = event; - const { sql } = query; + const { parameters, sql } = query; const duration = queryDurationMillis / 1000; dbQueriesCounter.inc(); dbQueryDurationHistogram.observe(duration); - const parameters = query.parameters.map(serializeParameter); - if (event.level === 'query') { - logi({ level: 'debug', ns: 'ditto.sql', sql, parameters, duration }); + logi({ level: 'debug', ns: 'ditto.sql', sql, parameters: parameters as JsonValue, duration }); } if (event.level === 'error') { - logi({ level: 'error', ns: 'ditto.sql', sql, parameters, error: errorJson(event.error), duration }); + logi({ + level: 'error', + ns: 'ditto.sql', + sql, + parameters: parameters as JsonValue, + error: errorJson(event.error), + duration, + }); } }; - -/** Serialize parameter to JSON. */ -function serializeParameter(parameter: unknown): JsonValue { - if (Array.isArray(parameter)) { - return parameter.map(serializeParameter); - } - if ( - typeof parameter === 'string' || typeof parameter === 'number' || typeof parameter === 'boolean' || - parameter === null - ) { - return parameter; - } - try { - return JSON.stringify(parameter); - } catch { - return String(parameter); - } -} diff --git a/src/startup.ts b/src/startup.ts index 16439c0b..7227cb8a 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,10 +1,26 @@ // Starts up applications required to run before the HTTP server is on. +import { logi } from '@soapbox/logi'; +import { encodeHex } from '@std/encoding/hex'; import { Conf } from '@/config.ts'; import { cron } from '@/cron.ts'; import { startFirehose } from '@/firehose.ts'; import { startNotify } from '@/notify.ts'; +logi.handler = (log) => { + console.log(JSON.stringify(log, (_key, value) => { + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Uint8Array) { + return '\\x' + encodeHex(value); + } + + return value; + })); +}; + if (Conf.firehoseEnabled) { startFirehose(); } From 449daf1e35a117c00bb4c0b074f120df7191aff0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Jan 2025 10:06:21 -0600 Subject: [PATCH 33/47] ditto.http.response: use `error` level when status >= 500 --- log.json | 226 +++++++++++++++++++++++++++++++ scripts/deparameterize.ts | 45 ++++++ src/middleware/logiMiddleware.ts | 3 +- 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 log.json create mode 100644 scripts/deparameterize.ts diff --git a/log.json b/log.json new file mode 100644 index 00000000..4eff9bd2 --- /dev/null +++ b/log.json @@ -0,0 +1,226 @@ +{ + "level": "error", + "ns": "ditto.sql", + "sql": "select * from (select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"kind\" = any($1) and \"nostr_events\".\"pubkey\" = any($2) order by \"nostr_events\".\"created_at\" desc, \"nostr_events\".\"id\" asc limit $3) as \"e\" union all select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"kind\" = any($4) and (\"nostr_events\".\"tags_index\" @> $5 or \"nostr_events\".\"tags_index\" @> $6 or \"nostr_events\".\"tags_index\" @> $7 or \"nostr_events\".\"tags_index\" @> $8 or \"nostr_events\".\"tags_index\" @> $9 or \"nostr_events\".\"tags_index\" @> $10 or \"nostr_events\".\"tags_index\" @> $11 or \"nostr_events\".\"tags_index\" @> $12 or \"nostr_events\".\"tags_index\" @> $13 or \"nostr_events\".\"tags_index\" @> $14 or \"nostr_events\".\"tags_index\" @> $15 or \"nostr_events\".\"tags_index\" @> $16 or \"nostr_events\".\"tags_index\" @> $17 or \"nostr_events\".\"tags_index\" @> $18 or \"nostr_events\".\"tags_index\" @> $19 or \"nostr_events\".\"tags_index\" @> $20 or \"nostr_events\".\"tags_index\" @> $21 or \"nostr_events\".\"tags_index\" @> $22 or \"nostr_events\".\"tags_index\" @> $23 or \"nostr_events\".\"tags_index\" @> $24 or \"nostr_events\".\"tags_index\" @> $25 or \"nostr_events\".\"tags_index\" @> $26 or \"nostr_events\".\"tags_index\" @> $27 or \"nostr_events\".\"tags_index\" @> $28 or \"nostr_events\".\"tags_index\" @> $29 or \"nostr_events\".\"tags_index\" @> $30 or \"nostr_events\".\"tags_index\" @> $31 or \"nostr_events\".\"tags_index\" @> $32 or \"nostr_events\".\"tags_index\" @> $33 or \"nostr_events\".\"tags_index\" @> $34 or \"nostr_events\".\"tags_index\" @> $35 or \"nostr_events\".\"tags_index\" @> $36 or \"nostr_events\".\"tags_index\" @> $37 or \"nostr_events\".\"tags_index\" @> $38 or \"nostr_events\".\"tags_index\" @> $39 or \"nostr_events\".\"tags_index\" @> $40 or \"nostr_events\".\"tags_index\" @> $41 or \"nostr_events\".\"tags_index\" @> $42 or \"nostr_events\".\"tags_index\" @> $43 or \"nostr_events\".\"tags_index\" @> $44 or \"nostr_events\".\"tags_index\" @> $45 or \"nostr_events\".\"tags_index\" @> $46 or \"nostr_events\".\"tags_index\" @> $47 or \"nostr_events\".\"tags_index\" @> $48 or \"nostr_events\".\"tags_index\" @> $49 or \"nostr_events\".\"tags_index\" @> $50 or \"nostr_events\".\"tags_index\" @> $51 or \"nostr_events\".\"tags_index\" @> $52 or \"nostr_events\".\"tags_index\" @> $53) order by \"nostr_events\".\"created_at\" desc, \"nostr_events\".\"id\" asc limit $54) as \"e\" union all select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"kind\" = any($55) and \"nostr_events\".\"pubkey\" = any($56) order by \"nostr_events\".\"created_at\" desc, \"nostr_events\".\"id\" asc limit $57) as \"e\" union all select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"id\" = any($58) and \"nostr_events\".\"kind\" = any($59) limit $60) as \"e\" union all select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"kind\" = any($61) and \"nostr_events\".\"pubkey\" = any($62) order by \"nostr_events\".\"created_at\" desc, \"nostr_events\".\"id\" asc limit $63) as \"e\" union all select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"kind\" = any($64) and \"nostr_events\".\"pubkey\" = any($65) order by \"nostr_events\".\"created_at\" desc, \"nostr_events\".\"id\" asc limit $66) as \"e\" union all select * from (select \"nostr_events\".* from \"nostr_events\" where \"nostr_events\".\"kind\" = any($67) and (\"nostr_events\".\"tags_index\" @> $68 or \"nostr_events\".\"tags_index\" @> $69 or \"nostr_events\".\"tags_index\" @> $70 or \"nostr_events\".\"tags_index\" @> $71 or \"nostr_events\".\"tags_index\" @> $72 or \"nostr_events\".\"tags_index\" @> $73 or \"nostr_events\".\"tags_index\" @> $74 or \"nostr_events\".\"tags_index\" @> $75 or \"nostr_events\".\"tags_index\" @> $76 or \"nostr_events\".\"tags_index\" @> $77 or \"nostr_events\".\"tags_index\" @> $78 or \"nostr_events\".\"tags_index\" @> $79 or \"nostr_events\".\"tags_index\" @> $80 or \"nostr_events\".\"tags_index\" @> $81 or \"nostr_events\".\"tags_index\" @> $82 or \"nostr_events\".\"tags_index\" @> $83 or \"nostr_events\".\"tags_index\" @> $84 or \"nostr_events\".\"tags_index\" @> $85 or \"nostr_events\".\"tags_index\" @> $86 or \"nostr_events\".\"tags_index\" @> $87 or \"nostr_events\".\"tags_index\" @> $88 or \"nostr_events\".\"tags_index\" @> $89 or \"nostr_events\".\"tags_index\" @> $90 or \"nostr_events\".\"tags_index\" @> $91 or \"nostr_events\".\"tags_index\" @> $92 or \"nostr_events\".\"tags_index\" @> $93 or \"nostr_events\".\"tags_index\" @> $94 or \"nostr_events\".\"tags_index\" @> $95 or \"nostr_events\".\"tags_index\" @> $96 or \"nostr_events\".\"tags_index\" @> $97 or \"nostr_events\".\"tags_index\" @> $98 or \"nostr_events\".\"tags_index\" @> $99 or \"nostr_events\".\"tags_index\" @> $100 or \"nostr_events\".\"tags_index\" @> $101 or \"nostr_events\".\"tags_index\" @> $102 or \"nostr_events\".\"tags_index\" @> $103 or \"nostr_events\".\"tags_index\" @> $104 or \"nostr_events\".\"tags_index\" @> $105 or \"nostr_events\".\"tags_index\" @> $106 or \"nostr_events\".\"tags_index\" @> $107 or \"nostr_events\".\"tags_index\" @> $108 or \"nostr_events\".\"tags_index\" @> $109 or \"nostr_events\".\"tags_index\" @> $110 or \"nostr_events\".\"tags_index\" @> $111 or \"nostr_events\".\"tags_index\" @> $112 or \"nostr_events\".\"tags_index\" @> $113 or \"nostr_events\".\"tags_index\" @> $114 or \"nostr_events\".\"tags_index\" @> $115 or \"nostr_events\".\"tags_index\" @> $116 or \"nostr_events\".\"tags_index\" @> $117 or \"nostr_events\".\"tags_index\" @> $118 or \"nostr_events\".\"tags_index\" @> $119 or \"nostr_events\".\"tags_index\" @> $120 or \"nostr_events\".\"tags_index\" @> $121 or \"nostr_events\".\"tags_index\" @> $122 or \"nostr_events\".\"tags_index\" @> $123 or \"nostr_events\".\"tags_index\" @> $124 or \"nostr_events\".\"tags_index\" @> $125 or \"nostr_events\".\"tags_index\" @> $126 or \"nostr_events\".\"tags_index\" @> $127 or \"nostr_events\".\"tags_index\" @> $128 or \"nostr_events\".\"tags_index\" @> $129 or \"nostr_events\".\"tags_index\" @> $130 or \"nostr_events\".\"tags_index\" @> $131 or \"nostr_events\".\"tags_index\" @> $132 or \"nostr_events\".\"tags_index\" @> $133 or \"nostr_events\".\"tags_index\" @> $134 or \"nostr_events\".\"tags_index\" @> $135 or \"nostr_events\".\"tags_index\" @> $136 or \"nostr_events\".\"tags_index\" @> $137 or \"nostr_events\".\"tags_index\" @> $138 or \"nostr_events\".\"tags_index\" @> $139 or \"nostr_events\".\"tags_index\" @> $140 or \"nostr_events\".\"tags_index\" @> $141 or \"nostr_events\".\"tags_index\" @> $142 or \"nostr_events\".\"tags_index\" @> $143 or \"nostr_events\".\"tags_index\" @> $144 or \"nostr_events\".\"tags_index\" @> $145 or \"nostr_events\".\"tags_index\" @> $146 or \"nostr_events\".\"tags_index\" @> $147 or \"nostr_events\".\"tags_index\" @> $148 or \"nostr_events\".\"tags_index\" @> $149 or \"nostr_events\".\"tags_index\" @> $150 or \"nostr_events\".\"tags_index\" @> $151) order by \"nostr_events\".\"created_at\" desc, \"nostr_events\".\"id\" asc limit $152) as \"e\") as \"e\" limit $153", + "parameters": [ + [1311, 30311], + [ + "02f0a63d98f72eb5224509914b4c93763130442e984ad8795176a4b1f91bde2c", + "068334b2f9cd5e30b43866c4b60bd31234e29be9bc11b06af037ec248df4f7ae", + "175f568d77fb0cb7400f0ddd8aed1738cd797532b314ef053a1669d4dba7433a", + "1e7168756bccf2309ca35661d06bab6bcd0448420ab349aabddaaf02746b1515", + "3ebc74907d1f928f209ef210e872cac033eaf3ff89e6853286d45d91e351ef9e", + "4a565f2661964aef0c3ee87b99c9a5731aa8dd8a80326f96a46fd7f207e8613a", + "7de29c8960c03250fe4d056b2fc03205534a0438164e7f9fd172c3f1f3707aeb", + "874943db57c24414f2892f122781be219f5c84b167ab7918f2ce0d9399f4404f", + "877308276be50ce9bafa7e5e374e4fcbf5e9859a21918f34baefd000746b7d35", + "b0962615c3193c379b247f2e464ef5096cff45cd5b2d715d8c80c057b0a562b9", + "c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1", + "f4df339e62003bbee1ab82df38a8a7bcae2a297932d86ba78d1484195509ce38", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106", + "fe3254bc4e54721b976e6b2ca04ef7040fcc00c49e10498cc8134036592000ec" + ], + 300, + [30311], + { "p": ["068334b2f9cd5e30b43866c4b60bd31234e29be9bc11b06af037ec248df4f7ae"] }, + { "p": ["b111f67497d54b95ce4954853f9270199fc16a2cee6fcc2832bb9ab91581b9ce"] }, + { "p": ["1d485daf0a86dea7b549eaa80b8b215c7518f5bedc179470efe0f4f854130429"] }, + { "p": ["3ebc74907d1f928f209ef210e872cac033eaf3ff89e6853286d45d91e351ef9e"] }, + { "p": ["f2c96c97f6419a538f84cf3fa72e2194605e1848096e6e5170cce5b76799d400"] }, + { "p": ["877308276be50ce9bafa7e5e374e4fcbf5e9859a21918f34baefd000746b7d35"] }, + { "p": ["02f0a63d98f72eb5224509914b4c93763130442e984ad8795176a4b1f91bde2c"] }, + { "p": ["9358c67695d9e78bde2bf3ce1eb0a5059553687632a177e7d25deeff9f2912fc"] }, + { "p": ["f3b633c30007c2fbedbbd028c2e973066504c15138b22d5c24f16a65f1a90ec4"] }, + { "p": ["805e3c98b42a2175a081666b4e077bab32136ea6cf4b9976a952569917d9e329"] }, + { "p": ["7de29c8960c03250fe4d056b2fc03205534a0438164e7f9fd172c3f1f3707aeb"] }, + { "p": ["39cc53c9e3f7d4980b21bea5ebc8a5b9cdf7fa6539430b5a826e8ad527168656"] }, + { "p": ["175f568d77fb0cb7400f0ddd8aed1738cd797532b314ef053a1669d4dba7433a"] }, + { "p": ["83d8bb23328c67ece2adf1306db97e3f027b853d8bdaf226d01c2e0f2ceade2e"] }, + { "p": ["b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"] }, + { "p": ["be7bf5de068c1d842ed34a7c270507ec940f5ea51671cfd062a95e9d09420d0a"] }, + { "p": ["4b03001ce314dc42cf52e78234fdc1ed3e6a8c9556ef9e9a3b7de641cca3da71"] }, + { "p": ["59c2e15ad7bc0b5c97b8438b2763a5c409ff76ab985ab5f1f47c4bcdd25e6e8d"] }, + { "p": ["f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106"] }, + { "p": ["4a565f2661964aef0c3ee87b99c9a5731aa8dd8a80326f96a46fd7f207e8613a"] }, + { "p": ["b0962615c3193c379b247f2e464ef5096cff45cd5b2d715d8c80c057b0a562b9"] }, + { "p": ["58ead82fa15b550094f7f5fe4804e0fe75b779dbef2e9b20511eccd69e6d08f9"] }, + { "p": ["a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"] }, + { "p": ["f7849c6c90c4ffe3be8059bd899d85d3fd38c0bb79749f9e79653820351ad8f8"] }, + { "p": ["d9a3041b0aa3cdf3b74bb3ad043da8a40ca149c891b2049b29f346b79225218c"] }, + { "p": ["8c1f616306523c19b9cba6e5c72d7f8efd55940620f40f24a5f1f253ac921ba2"] }, + { "p": ["787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2"] }, + { "p": ["266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5"] }, + { "p": ["b9a537523bba2fcdae857d90d8a760de4f2139c9f90d986f747ce7d0ec0d173d"] }, + { "p": ["af740d198babb8c7b82d0a4718eb354bb3f6af9a98639b85d4a5cf1371caba85"] }, + { "p": ["fe3254bc4e54721b976e6b2ca04ef7040fcc00c49e10498cc8134036592000ec"] }, + { "p": ["5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54"] }, + { "p": ["1e7168756bccf2309ca35661d06bab6bcd0448420ab349aabddaaf02746b1515"] }, + { "p": ["06bc9ab7c06cbaa8fb9089ea326736340473fc54b77ae1e093766b70427c48f5"] }, + { "p": ["f985d309197c805e1719c73185b574fc3ee407d7c1b6157dee99c6ace2599bbb"] }, + { "p": ["c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd"] }, + { "p": ["c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1"] }, + { "p": ["22ada61c62ff6c743d28981309269744278b49172a53a44c5f61517628021425"] }, + { "p": ["874943db57c24414f2892f122781be219f5c84b167ab7918f2ce0d9399f4404f"] }, + { "p": ["ee85604f8ec6e4e24f8eaf2a624d042ebd431dae448fe11779adcfb6bb78575e"] }, + { "p": ["eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f"] }, + { "p": ["d8a6ecf0c396eaa8f79a4497fe9b77dc977633451f3ca5c634e208659116647b"] }, + { "p": ["de90c5db36a4011f9d584dfc18de1a5724686867984793ef526331b51f8b43e9"] }, + { "p": ["f6900e6b42a83a6d66589714de49ac86919c6464857d9e164b953bcf9c7e939d"] }, + { "p": ["977b690ce1f7d254efb8e4ed985240b0084424e4151ab118ca7b62129d267f3d"] }, + { "p": ["4506e04e4b7079ce07e38e9875678a81ad33a456c696d708ef8e9a2d8c16ba04"] }, + { "p": ["76596e4aec7ff38009c0b20c49c80331ff92cdd58535d9f83b824d07a92a8e88"] }, + { "p": ["f4df339e62003bbee1ab82df38a8a7bcae2a297932d86ba78d1484195509ce38"] }, + { "p": ["e0cf1bd90cced52f578c2e090593b0cd169780317d43ac46927abff2d61da062"] }, + 100, + [42], + [ + "02f0a63d98f72eb5224509914b4c93763130442e984ad8795176a4b1f91bde2c", + "068334b2f9cd5e30b43866c4b60bd31234e29be9bc11b06af037ec248df4f7ae", + "175f568d77fb0cb7400f0ddd8aed1738cd797532b314ef053a1669d4dba7433a", + "1e7168756bccf2309ca35661d06bab6bcd0448420ab349aabddaaf02746b1515", + "3ebc74907d1f928f209ef210e872cac033eaf3ff89e6853286d45d91e351ef9e", + "4a565f2661964aef0c3ee87b99c9a5731aa8dd8a80326f96a46fd7f207e8613a", + "7de29c8960c03250fe4d056b2fc03205534a0438164e7f9fd172c3f1f3707aeb", + "874943db57c24414f2892f122781be219f5c84b167ab7918f2ce0d9399f4404f", + "877308276be50ce9bafa7e5e374e4fcbf5e9859a21918f34baefd000746b7d35", + "b0962615c3193c379b247f2e464ef5096cff45cd5b2d715d8c80c057b0a562b9", + "c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1", + "f4df339e62003bbee1ab82df38a8a7bcae2a297932d86ba78d1484195509ce38", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106", + "fe3254bc4e54721b976e6b2ca04ef7040fcc00c49e10498cc8134036592000ec" + ], + 500, + ["5a5cc47d07308f553c294758f6e0bb066cc7aa760e2cae9b29b7c79df7dfb69d"], + [40, 42], + 1, + [30402], + [ + "02f0a63d98f72eb5224509914b4c93763130442e984ad8795176a4b1f91bde2c", + "068334b2f9cd5e30b43866c4b60bd31234e29be9bc11b06af037ec248df4f7ae", + "175f568d77fb0cb7400f0ddd8aed1738cd797532b314ef053a1669d4dba7433a", + "1e7168756bccf2309ca35661d06bab6bcd0448420ab349aabddaaf02746b1515", + "3ebc74907d1f928f209ef210e872cac033eaf3ff89e6853286d45d91e351ef9e", + "4a565f2661964aef0c3ee87b99c9a5731aa8dd8a80326f96a46fd7f207e8613a", + "7de29c8960c03250fe4d056b2fc03205534a0438164e7f9fd172c3f1f3707aeb", + "874943db57c24414f2892f122781be219f5c84b167ab7918f2ce0d9399f4404f", + "877308276be50ce9bafa7e5e374e4fcbf5e9859a21918f34baefd000746b7d35", + "b0962615c3193c379b247f2e464ef5096cff45cd5b2d715d8c80c057b0a562b9", + "c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1", + "f4df339e62003bbee1ab82df38a8a7bcae2a297932d86ba78d1484195509ce38", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106", + "fe3254bc4e54721b976e6b2ca04ef7040fcc00c49e10498cc8134036592000ec" + ], + 300, + [34550, 4550], + [ + "02f0a63d98f72eb5224509914b4c93763130442e984ad8795176a4b1f91bde2c", + "068334b2f9cd5e30b43866c4b60bd31234e29be9bc11b06af037ec248df4f7ae", + "175f568d77fb0cb7400f0ddd8aed1738cd797532b314ef053a1669d4dba7433a", + "1e7168756bccf2309ca35661d06bab6bcd0448420ab349aabddaaf02746b1515", + "3ebc74907d1f928f209ef210e872cac033eaf3ff89e6853286d45d91e351ef9e", + "4a565f2661964aef0c3ee87b99c9a5731aa8dd8a80326f96a46fd7f207e8613a", + "7de29c8960c03250fe4d056b2fc03205534a0438164e7f9fd172c3f1f3707aeb", + "874943db57c24414f2892f122781be219f5c84b167ab7918f2ce0d9399f4404f", + "877308276be50ce9bafa7e5e374e4fcbf5e9859a21918f34baefd000746b7d35", + "b0962615c3193c379b247f2e464ef5096cff45cd5b2d715d8c80c057b0a562b9", + "c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1", + "f4df339e62003bbee1ab82df38a8a7bcae2a297932d86ba78d1484195509ce38", + "f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106", + "fe3254bc4e54721b976e6b2ca04ef7040fcc00c49e10498cc8134036592000ec" + ], + 300, + [40, 41, 42], + { "t": ["krux"] }, + { "t": ["krux"] }, + { "t": ["KRUX"] }, + { "t": ["Krux"] }, + { "t": ["seedsigner"] }, + { "t": ["seedsigner"] }, + { "t": ["SEEDSIGNER"] }, + { "t": ["Seedsigner"] }, + { "t": ["wallet"] }, + { "t": ["wallet"] }, + { "t": ["WALLET"] }, + { "t": ["Wallet"] }, + { "t": ["thinkpad"] }, + { "t": ["thinkpad"] }, + { "t": ["THINKPAD"] }, + { "t": ["Thinkpad"] }, + { "t": ["linux"] }, + { "t": ["linux"] }, + { "t": ["LINUX"] }, + { "t": ["Linux"] }, + { "t": ["gentoo"] }, + { "t": ["gentoo"] }, + { "t": ["GENTOO"] }, + { "t": ["Gentoo"] }, + { "t": ["monero"] }, + { "t": ["monero"] }, + { "t": ["MONERO"] }, + { "t": ["Monero"] }, + { "t": ["cakewallet"] }, + { "t": ["cakewallet"] }, + { "t": ["CAKEWALLET"] }, + { "t": ["Cakewallet"] }, + { "t": ["havenoretro"] }, + { "t": ["havenoretro"] }, + { "t": ["HAVENORETRO"] }, + { "t": ["Havenoretro"] }, + { "t": ["amethyst"] }, + { "t": ["amethyst"] }, + { "t": ["AMETHYST"] }, + { "t": ["Amethyst"] }, + { "t": ["docchain"] }, + { "t": ["docchain"] }, + { "t": ["DOCCHAIN"] }, + { "t": ["Docchain"] }, + { "t": ["foamed"] }, + { "t": ["foamed"] }, + { "t": ["FOAMED"] }, + { "t": ["Foamed"] }, + { "t": ["obsidian"] }, + { "t": ["obsidian"] }, + { "t": ["OBSIDIAN"] }, + { "t": ["Obsidian"] }, + { "t": ["4runner"] }, + { "t": ["4runner"] }, + { "t": ["4RUNNER"] }, + { "t": ["4runner"] }, + { "t": ["stackwallet"] }, + { "t": ["stackwallet"] }, + { "t": ["STACKWALLET"] }, + { "t": ["Stackwallet"] }, + { "t": ["tinyseed"] }, + { "t": ["tinyseed"] }, + { "t": ["TINYSEED"] }, + { "t": ["Tinyseed"] }, + { "t": ["keystone"] }, + { "t": ["keystone"] }, + { "t": ["KEYSTONE"] }, + { "t": ["Keystone"] }, + { "t": ["xmrsigner"] }, + { "t": ["xmrsigner"] }, + { "t": ["XMRSIGNER"] }, + { "t": ["Xmrsigner"] }, + { "t": ["zapchat"] }, + { "t": ["zapchat"] }, + { "t": ["ZAPCHAT"] }, + { "t": ["Zapchat"] }, + { "t": ["zellij"] }, + { "t": ["zellij"] }, + { "t": ["ZELLIJ"] }, + { "t": ["Zellij"] }, + { "t": ["gunstr"] }, + { "t": ["gunstr"] }, + { "t": ["GUNSTR"] }, + { "t": ["Gunstr"] }, + 300, + 100 + ], + "error": { + "name": "PostgresError", + "message": "canceling statement due to statement timeout", + "stack": "PostgresError: canceling statement due to statement timeout\n at ErrorResponse (https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/src/connection.js:793:26)\n at handle (https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/src/connection.js:479:6)\n at data (https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/src/connection.js:318:9)\n at https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/polyfills.js:138:30\n at Array.forEach ()\n at call (https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/polyfills.js:138:16)\n at success (https://gitlab.com/soapbox-pub/postgres.js/-/raw/e79d7d2039446fbf7a37d4eca0d17e94a94b8b53/deno/polyfills.js:98:9)\n at eventLoopTick (ext:core/01_core.js:177:7)" + }, + "duration": 1.0028296069999996 +} diff --git a/scripts/deparameterize.ts b/scripts/deparameterize.ts new file mode 100644 index 00000000..1b5fdfa6 --- /dev/null +++ b/scripts/deparameterize.ts @@ -0,0 +1,45 @@ +const decoder = new TextDecoder(); + +for await (const chunk of Deno.stdin.readable) { + const text = decoder.decode(chunk); + + const { sql, parameters } = JSON.parse(text) as { sql: string; parameters: unknown[] }; + + let result = sql; + + for (let i = 0; i < parameters.length; i++) { + const param = parameters[i]; + + result = result.replace(`$${i + 1}`, serializeParameter(param)); + } + + console.log(result + ';'); +} + +function serializeParameter(param: unknown): string { + if (param === null) { + return 'null'; + } + + if (typeof param === 'string') { + return `'${param}'`; + } + + if (typeof param === 'number' || typeof param === 'boolean') { + return param.toString(); + } + + if (param instanceof Date) { + return `'${param.toISOString()}'`; + } + + if (Array.isArray(param)) { + return `'{${param.join(',')}}'`; + } + + if (typeof param === 'object') { + return `'${JSON.stringify(param)}'`; + } + + return JSON.stringify(param); +} diff --git a/src/middleware/logiMiddleware.ts b/src/middleware/logiMiddleware.ts index 0c3dedda..26233f27 100644 --- a/src/middleware/logiMiddleware.ts +++ b/src/middleware/logiMiddleware.ts @@ -13,6 +13,7 @@ export const logiMiddleware: MiddlewareHandler = async (c, next) => { const end = new Date(); const delta = (end.getTime() - start.getTime()) / 1000; + const level = c.res.status >= 500 ? 'error' : 'info'; - logi({ level: 'info', ns: 'ditto.http.response', method, pathname, status: c.res.status, delta }); + logi({ level, ns: 'ditto.http.response', method, pathname, status: c.res.status, delta }); }; From 8f4ae833ca6d623e35ef78a4b31f58d3e286c4fc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Jan 2025 12:30:46 -0600 Subject: [PATCH 34/47] logi: message -> msg --- src/DittoPush.ts | 2 +- src/controllers/api/streaming.ts | 2 +- src/controllers/api/trends.ts | 8 ++++---- src/controllers/error.ts | 2 +- src/controllers/frontend.ts | 2 +- src/controllers/nostr/relay.ts | 2 +- src/db/DittoDB.ts | 8 ++++---- src/sentry.ts | 4 ++-- src/server.ts | 2 +- src/storages.ts | 2 +- src/trends.ts | 10 +++++----- src/utils/log.ts | 4 ++-- src/workers/policy.ts | 6 +++--- 13 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/DittoPush.ts b/src/DittoPush.ts index b8e105d9..7f5dafa0 100644 --- a/src/DittoPush.ts +++ b/src/DittoPush.ts @@ -24,7 +24,7 @@ export class DittoPush { logi({ level: 'warn', ns: 'ditto.push', - message: 'VAPID keys are not set. Push notifications will be disabled.', + msg: 'VAPID keys are not set. Push notifications will be disabled.', }); } })(); diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index f067cd43..405de96c 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -128,7 +128,7 @@ const streamingController: AppController = async (c) => { } } } catch (e) { - logi({ level: 'error', ns: 'ditto.streaming', message: 'Error in streaming', error: errorJson(e) }); + logi({ level: 'error', ns: 'ditto.streaming', msg: 'Error in streaming', error: errorJson(e) }); } } diff --git a/src/controllers/api/trends.ts b/src/controllers/api/trends.ts index 6b064ed0..c2577e13 100644 --- a/src/controllers/api/trends.ts +++ b/src/controllers/api/trends.ts @@ -18,7 +18,7 @@ let trendingHashtagsCache = getTrendingHashtags().catch((e: unknown) => { level: 'error', ns: 'ditto.trends.api', type: 'tags', - message: 'Failed to get trending hashtags', + msg: 'Failed to get trending hashtags', error: errorJson(e), }); return Promise.resolve([]); @@ -33,7 +33,7 @@ Deno.cron('update trending hashtags cache', '35 * * * *', async () => { level: 'error', ns: 'ditto.trends.api', type: 'tags', - message: 'Failed to get trending hashtags', + msg: 'Failed to get trending hashtags', error: errorJson(e), }); } @@ -76,7 +76,7 @@ let trendingLinksCache = getTrendingLinks().catch((e: unknown) => { level: 'error', ns: 'ditto.trends.api', type: 'links', - message: 'Failed to get trending links', + msg: 'Failed to get trending links', error: errorJson(e), }); return Promise.resolve([]); @@ -91,7 +91,7 @@ Deno.cron('update trending links cache', '50 * * * *', async () => { level: 'error', ns: 'ditto.trends.api', type: 'links', - message: 'Failed to get trending links', + msg: 'Failed to get trending links', error: errorJson(e), }); } diff --git a/src/controllers/error.ts b/src/controllers/error.ts index a6a802ea..50962fcc 100644 --- a/src/controllers/error.ts +++ b/src/controllers/error.ts @@ -19,7 +19,7 @@ export const errorHandler: ErrorHandler = (err, c) => { return c.json({ error: 'The server was unable to respond in a timely manner' }, 500); } - logi({ level: 'error', ns: 'ditto.http', message: 'Unhandled error', error: errorJson(err) }); + logi({ level: 'error', ns: 'ditto.http', msg: 'Unhandled error', error: errorJson(err) }); return c.json({ error: 'Something went wrong' }, 500); }; diff --git a/src/controllers/frontend.ts b/src/controllers/frontend.ts index ad5c00dc..413b4ade 100644 --- a/src/controllers/frontend.ts +++ b/src/controllers/frontend.ts @@ -27,7 +27,7 @@ export const frontendController: AppMiddleware = async (c) => { const meta = renderMetadata(c.req.url, entities); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { - logi({ level: 'error', ns: 'ditto.frontend', message: 'Error building meta tags', error: errorJson(e) }); + logi({ level: 'error', ns: 'ditto.frontend', msg: 'Error building meta tags', error: errorJson(e) }); return c.html(content); } } diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 5412cdc1..aa355928 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -168,7 +168,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { send(['OK', event.id, false, e.message]); } else { send(['OK', event.id, false, 'error: something went wrong']); - logi({ level: 'error', ns: 'ditto.relay', message: 'Error in relay', error: errorJson(e) }); + logi({ level: 'error', ns: 'ditto.relay', msg: 'Error in relay', error: errorJson(e) }); } } } diff --git a/src/db/DittoDB.ts b/src/db/DittoDB.ts index ddc0b86d..8d242237 100644 --- a/src/db/DittoDB.ts +++ b/src/db/DittoDB.ts @@ -39,14 +39,14 @@ export class DittoDB { }), }); - logi({ level: 'info', ns: 'ditto.db.migration', message: 'Running migrations...', state: 'started' }); + logi({ level: 'info', ns: 'ditto.db.migration', msg: 'Running migrations...', state: 'started' }); const { results, error } = await migrator.migrateToLatest(); if (error) { logi({ level: 'fatal', ns: 'ditto.db.migration', - message: 'Migration failed.', + msg: 'Migration failed.', state: 'failed', results: results as unknown as JsonValue, error: errorJson(error), @@ -54,12 +54,12 @@ export class DittoDB { Deno.exit(1); } else { if (!results?.length) { - logi({ level: 'info', ns: 'ditto.db.migration', message: 'Everything up-to-date.', state: 'skipped' }); + logi({ level: 'info', ns: 'ditto.db.migration', msg: 'Everything up-to-date.', state: 'skipped' }); } else { logi({ level: 'info', ns: 'ditto.db.migration', - message: 'Migrations finished!', + msg: 'Migrations finished!', state: 'migrated', results: results as unknown as JsonValue, }); diff --git a/src/sentry.ts b/src/sentry.ts index 29a4288a..4875a12e 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -5,11 +5,11 @@ import { Conf } from '@/config.ts'; // Sentry if (Conf.sentryDsn) { - logi({ level: 'info', ns: 'ditto.sentry', message: 'Sentry enabled.', enabled: true }); + logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry enabled.', enabled: true }); Sentry.init({ dsn: Conf.sentryDsn, tracesSampleRate: 1.0, }); } else { - logi({ level: 'info', ns: 'ditto.sentry', message: 'Sentry not configured. Skipping.', enabled: false }); + logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry not configured. Skipping.', enabled: false }); } diff --git a/src/server.ts b/src/server.ts index 513e55bd..c5815537 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,6 @@ import { Conf } from '@/config.ts'; Deno.serve({ port: Conf.port, onListen({ hostname, port }): void { - logi({ level: 'info', ns: 'ditto.server', message: `Listening on http://${hostname}:${port}`, hostname, port }); + logi({ level: 'info', ns: 'ditto.server', msg: `Listening on http://${hostname}:${port}`, hostname, port }); }, }, app.fetch); diff --git a/src/storages.ts b/src/storages.ts index 8812f298..4a26ef32 100644 --- a/src/storages.ts +++ b/src/storages.ts @@ -94,7 +94,7 @@ export class Storages { logi({ level: 'info', ns: 'ditto.pool', - message: `connecting to ${activeRelays.length} relays`, + msg: `connecting to ${activeRelays.length} relays`, relays: activeRelays, }); diff --git a/src/trends.ts b/src/trends.ts index 1531597a..ed0ea930 100644 --- a/src/trends.ts +++ b/src/trends.ts @@ -75,7 +75,7 @@ export async function updateTrendingTags( values?: string[], ) { const params = { l, tagName, kinds, limit, extra, aliases, values }; - logi({ level: 'info', ns: 'ditto.trends', message: 'Updating trending', ...params }); + logi({ level: 'info', ns: 'ditto.trends', msg: 'Updating trending', ...params }); const kysely = await Storages.kysely(); const signal = AbortSignal.timeout(1000); @@ -94,9 +94,9 @@ export async function updateTrendingTags( }, values); if (trends.length) { - logi({ level: 'info', ns: 'ditto.trends', message: 'Trends found', trends, ...params }); + logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends found', trends, ...params }); } else { - logi({ level: 'info', ns: 'ditto.trends', message: 'No trends found. Skipping.', ...params }); + logi({ level: 'info', ns: 'ditto.trends', msg: 'No trends found. Skipping.', ...params }); return; } @@ -114,9 +114,9 @@ export async function updateTrendingTags( }); await handleEvent(label, { source: 'internal', signal }); - logi({ level: 'info', ns: 'ditto.trends', message: 'Trends updated', ...params }); + logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends updated', ...params }); } catch (e) { - logi({ level: 'error', ns: 'ditto.trends', message: 'Error updating trends', ...params, error: errorJson(e) }); + logi({ level: 'error', ns: 'ditto.trends', msg: 'Error updating trends', ...params, error: errorJson(e) }); } } diff --git a/src/utils/log.ts b/src/utils/log.ts index 4a96e39a..4d005a6f 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -3,8 +3,8 @@ import { JsonValue } from '@std/json'; /** Serialize an error into JSON for JSON logging. */ export function errorJson(error: unknown): JsonValue { if (error instanceof Error) { - return { name: error.name, message: error.message, stack: error.stack }; + return { name: error.name, msg: error.message, stack: error.stack }; } - return { name: 'unknown', message: String(error) }; + return { name: 'unknown', msg: String(error) }; } diff --git a/src/workers/policy.ts b/src/workers/policy.ts index 92a9dd76..fdc33698 100644 --- a/src/workers/policy.ts +++ b/src/workers/policy.ts @@ -56,7 +56,7 @@ class PolicyWorker implements NPolicy { logi({ level: 'info', ns: 'ditto.system.policy', - message: 'Using custom policy', + msg: 'Using custom policy', path: Conf.policy, enabled: true, }); @@ -65,7 +65,7 @@ class PolicyWorker implements NPolicy { logi({ level: 'info', ns: 'ditto.system.policy', - message: 'Custom policy not found ', + msg: 'Custom policy not found ', path: null, enabled: false, }); @@ -77,7 +77,7 @@ class PolicyWorker implements NPolicy { logi({ level: 'warn', ns: 'ditto.system.policy', - message: 'Custom policies are not supported with PGlite. The policy is disabled.', + msg: 'Custom policies are not supported with PGlite. The policy is disabled.', path: Conf.policy, enabled: false, }); From d5ff66a542fc8a5c4df41b146b89894e7bb23dfb Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 29 Jan 2025 20:41:22 -0300 Subject: [PATCH 35/47] feat: endpoint for creating NIP-60 wallet --- src/app.ts | 3 ++ src/controllers/api/ditto.ts | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/app.ts b/src/app.ts index 8ff16dc1..8bdeccf3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -43,6 +43,7 @@ import { captchaController, captchaVerifyController } from '@/controllers/api/ca import { adminRelaysController, adminSetRelaysController, + createCashuWalletController, deleteZapSplitsController, getZapSplitsController, nameRequestController, @@ -400,6 +401,8 @@ app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSpli app.post('/api/v1/ditto/zap', requireSigner, zapController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); +app.post('/api/v1/ditto/wallet/create', requireSigner, createCashuWalletController); + 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); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 3a1ce98d..5ee07b60 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,4 +1,5 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { bytesToString } from '@scure/base'; import { z } from 'zod'; import { AppController } from '@/app.ts'; @@ -19,6 +20,7 @@ import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; import { Storages } from '@/storages.ts'; import { updateListAdminEvent } from '@/utils/api.ts'; +import { generateSecretKey } from 'nostr-tools'; const markerSchema = z.enum(['read', 'write']); @@ -342,3 +344,63 @@ export const updateInstanceController: AppController = async (c) => { return c.json(204); }; + +const createCashuWalletSchema = z.object({ + description: z.string(), + relays: z.set(z.string().url()), + mints: z.set(z.string().url()).nonempty(), // must contain at least one item + name: z.string(), +}); + +export const createCashuWalletController: AppController = async (c) => { + const signer = c.get('signer')!; + const store = c.get('store'); + const pubkey = await signer.getPublicKey(); + const body = await parseBody(c.req.raw); + const { signal } = c.req.raw; + const result = createCashuWalletSchema.safeParse(body); + + if (!result.success) { + return c.json({ error: 'Bad request', schema: result.error }, 400); + } + + const event = await store.query([{ authors: [pubkey], kinds: [37375] }], { signal }); + if (event) { + return c.json({ error: 'You already have a wallet 😏', schema: result.error }, 400); + } + + const { description, relays, mints, name } = result.data; + relays.add(Conf.relay); + + const tags: string[][] = []; + + tags.push(['d', Math.random().toString(36).substring(3)]); + tags.push(['name', name]); + tags.push(['description', description]); + tags.push(['unit', 'sat']); + + for (const mint of mints) { + tags.push(['mint', mint]); + } + + for (const relay of relays) { + tags.push(['relay', relay]); + } + + const sk = generateSecretKey(); + const privkey = bytesToString('hex', sk); + + const contentTags = [ + ['privkey', privkey], + ]; + const encryptedContentTags = await signer.nip44?.encrypt(pubkey, JSON.stringify(contentTags)); + + // Wallet + await createEvent({ + kind: 37375, + content: encryptedContentTags, + tags, + }, c); + + return c.json(201); +}; From d19b925db00c929ee86eecd354c3138435c6ec65 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 29 Jan 2025 20:44:34 -0300 Subject: [PATCH 36/47] fix: get first event from query --- src/controllers/api/ditto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 5ee07b60..63f0561a 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -364,7 +364,7 @@ export const createCashuWalletController: AppController = async (c) => { return c.json({ error: 'Bad request', schema: result.error }, 400); } - const event = await store.query([{ authors: [pubkey], kinds: [37375] }], { signal }); + const [event] = await store.query([{ authors: [pubkey], kinds: [37375] }], { signal }); if (event) { return c.json({ error: 'You already have a wallet 😏', schema: result.error }, 400); } From b473898cef1640b83b00f91d7f6940caea7fd96c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 01:43:57 -0600 Subject: [PATCH 37/47] Upgrade Logi --- deno.json | 2 +- deno.lock | 8 ++++---- src/db/KyselyLogger.ts | 7 +++---- src/startup.ts | 17 ----------------- src/utils/log.ts | 10 ++++------ 5 files changed, 12 insertions(+), 32 deletions(-) diff --git a/deno.json b/deno.json index 40668402..4996439c 100644 --- a/deno.json +++ b/deno.json @@ -52,7 +52,7 @@ "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", - "@soapbox/logi": "jsr:@soapbox/logi@^0.2.1", + "@soapbox/logi": "jsr:@soapbox/logi@^0.3.0", "@soapbox/safe-fetch": "jsr:@soapbox/safe-fetch@^2.0.0", "@std/assert": "jsr:@std/assert@^0.225.1", "@std/cli": "jsr:@std/cli@^0.223.0", diff --git a/deno.lock b/deno.lock index 877a90de..6b28e2a4 100644 --- a/deno.lock +++ b/deno.lock @@ -49,7 +49,7 @@ "jsr:@nostrify/types@0.36": "0.36.0", "jsr:@nostrify/types@~0.30.1": "0.30.1", "jsr:@soapbox/kysely-pglite@1": "1.0.0", - "jsr:@soapbox/logi@~0.2.1": "0.2.1", + "jsr:@soapbox/logi@0.3": "0.3.0", "jsr:@soapbox/safe-fetch@2": "2.0.0", "jsr:@std/assert@0.223": "0.223.0", "jsr:@std/assert@0.224": "0.224.0", @@ -526,8 +526,8 @@ "npm:kysely@~0.27.4" ] }, - "@soapbox/logi@0.2.1": { - "integrity": "763d624c45adb74ec55e24911d14933d1883606c14701e171be7bfb76f9029be" + "@soapbox/logi@0.3.0": { + "integrity": "5aa5121e82422b0a1b5ec81790f75407c16c788d10af629cecef9a35d1b4c290" }, "@soapbox/safe-fetch@2.0.0": { "integrity": "f451d686501c76a0faa058fe9d2073676282a8a42c3b93c59159eb9191f11b5f", @@ -2353,7 +2353,7 @@ "jsr:@nostrify/policies@~0.36.1", "jsr:@nostrify/types@0.36", "jsr:@soapbox/kysely-pglite@1", - "jsr:@soapbox/logi@~0.2.1", + "jsr:@soapbox/logi@0.3", "jsr:@soapbox/safe-fetch@2", "jsr:@std/assert@~0.225.1", "jsr:@std/cli@0.223", diff --git a/src/db/KyselyLogger.ts b/src/db/KyselyLogger.ts index b640f5e5..45c10cc3 100644 --- a/src/db/KyselyLogger.ts +++ b/src/db/KyselyLogger.ts @@ -1,5 +1,4 @@ -import { logi } from '@soapbox/logi'; -import { JsonValue } from '@std/json'; +import { logi, LogiValue } from '@soapbox/logi'; import { Logger } from 'kysely'; import { dbQueriesCounter, dbQueryDurationHistogram } from '@/metrics.ts'; @@ -16,7 +15,7 @@ export const KyselyLogger: Logger = (event) => { dbQueryDurationHistogram.observe(duration); if (event.level === 'query') { - logi({ level: 'debug', ns: 'ditto.sql', sql, parameters: parameters as JsonValue, duration }); + logi({ level: 'debug', ns: 'ditto.sql', sql, parameters: parameters as LogiValue, duration }); } if (event.level === 'error') { @@ -24,7 +23,7 @@ export const KyselyLogger: Logger = (event) => { level: 'error', ns: 'ditto.sql', sql, - parameters: parameters as JsonValue, + parameters: parameters as LogiValue, error: errorJson(event.error), duration, }); diff --git a/src/startup.ts b/src/startup.ts index 7227cb8a..0cc2f26a 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,26 +1,9 @@ // Starts up applications required to run before the HTTP server is on. -import { logi } from '@soapbox/logi'; -import { encodeHex } from '@std/encoding/hex'; - import { Conf } from '@/config.ts'; import { cron } from '@/cron.ts'; import { startFirehose } from '@/firehose.ts'; import { startNotify } from '@/notify.ts'; -logi.handler = (log) => { - console.log(JSON.stringify(log, (_key, value) => { - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Uint8Array) { - return '\\x' + encodeHex(value); - } - - return value; - })); -}; - if (Conf.firehoseEnabled) { startFirehose(); } diff --git a/src/utils/log.ts b/src/utils/log.ts index 4d005a6f..28fcbf0d 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -1,10 +1,8 @@ -import { JsonValue } from '@std/json'; - /** Serialize an error into JSON for JSON logging. */ -export function errorJson(error: unknown): JsonValue { +export function errorJson(error: unknown): Error | null { if (error instanceof Error) { - return { name: error.name, msg: error.message, stack: error.stack }; + return error; + } else { + return null; } - - return { name: 'unknown', msg: String(error) }; } From 38f5a122844bcf5cda564ddd5d38aae6e91ae40c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 02:19:19 -0600 Subject: [PATCH 38/47] Log relay communication --- deno.json | 2 +- deno.lock | 18 +++++++++++++++++- src/storages.ts | 5 +++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/deno.json b/deno.json index 4996439c..1d04b07e 100644 --- a/deno.json +++ b/deno.json @@ -46,7 +46,7 @@ "@negrel/webpush": "jsr:@negrel/webpush@^0.3.0", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", "@nostrify/db": "jsr:@nostrify/db@^0.36.2", - "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.37.0", + "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.38.0", "@nostrify/policies": "jsr:@nostrify/policies@^0.36.1", "@nostrify/types": "jsr:@nostrify/types@^0.36.0", "@scure/base": "npm:@scure/base@^1.1.6", diff --git a/deno.lock b/deno.lock index 6b28e2a4..ddc1820a 100644 --- a/deno.lock +++ b/deno.lock @@ -35,6 +35,7 @@ "jsr:@nostrify/nostrify@0.32": "0.32.0", "jsr:@nostrify/nostrify@0.36": "0.36.2", "jsr:@nostrify/nostrify@0.37": "0.37.0", + "jsr:@nostrify/nostrify@0.38": "0.38.0", "jsr:@nostrify/nostrify@~0.22.1": "0.22.5", "jsr:@nostrify/nostrify@~0.22.4": "0.22.4", "jsr:@nostrify/nostrify@~0.22.5": "0.22.5", @@ -470,6 +471,21 @@ "npm:zod" ] }, + "@nostrify/nostrify@0.38.0": { + "integrity": "9ec7920057ee3a4dcbaef7e706dedea622bfdfdf0f6aac11047443f88d953deb", + "dependencies": [ + "jsr:@nostrify/types@0.36", + "jsr:@std/crypto", + "jsr:@std/encoding@~0.224.1", + "npm:@scure/base", + "npm:@scure/bip32", + "npm:@scure/bip39", + "npm:lru-cache@^10.2.0", + "npm:nostr-tools@^2.10.4", + "npm:websocket-ts", + "npm:zod" + ] + }, "@nostrify/policies@0.33.0": { "integrity": "c946b06d0527298b4d7c9819d142a10f522ba09eee76c37525aa4acfc5d87aee", "dependencies": [ @@ -2349,7 +2365,7 @@ "jsr:@lambdalisue/async@^2.1.1", "jsr:@negrel/webpush@0.3", "jsr:@nostrify/db@~0.36.2", - "jsr:@nostrify/nostrify@0.37", + "jsr:@nostrify/nostrify@0.38", "jsr:@nostrify/policies@~0.36.1", "jsr:@nostrify/types@0.36", "jsr:@soapbox/kysely-pglite@1", diff --git a/src/storages.ts b/src/storages.ts index 4a26ef32..765365f0 100644 --- a/src/storages.ts +++ b/src/storages.ts @@ -103,6 +103,11 @@ export class Storages { return new NRelay1(url, { // Skip event verification (it's done in the pipeline). verifyEvent: () => true, + log(log) { + if (log.level !== 'trace') { + logi(log); + } + }, }); }, reqRouter: async (filters) => { From 2ac2a45350be3c8563594598c432dcffa8fafe18 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 02:23:47 -0600 Subject: [PATCH 39/47] Actually do log traces --- src/storages.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/storages.ts b/src/storages.ts index 765365f0..0bccc534 100644 --- a/src/storages.ts +++ b/src/storages.ts @@ -104,9 +104,7 @@ export class Storages { // Skip event verification (it's done in the pipeline). verifyEvent: () => true, log(log) { - if (log.level !== 'trace') { - logi(log); - } + logi(log); }, }); }, From c6848b9ce2f1b9d599842a8651eed52de188f0f9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 02:33:19 -0600 Subject: [PATCH 40/47] Log events sent to our relay --- src/controllers/nostr/relay.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index aa355928..c3bb2c8c 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,4 +1,5 @@ import { logi } from '@soapbox/logi'; +import { JsonValue } from '@std/json'; import { NKinds, NostrClientCLOSE, @@ -64,6 +65,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { const result = n.json().pipe(n.clientMsg()).safeParse(e.data); if (result.success) { + logi({ level: 'trace', ns: 'ditto.relay.message', data: result.data as JsonValue }); relayMessagesCounter.inc({ verb: result.data[0] }); handleMsg(result.data); } else { From 6a34f8f6e53eb7e6d1a9b2191fbb355638bb6fc6 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 30 Jan 2025 11:07:15 -0300 Subject: [PATCH 41/47] fix: use zod array instead of zod set https://github.com/colinhacks/zod/issues/3963 --- src/controllers/api/ditto.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 63f0561a..d747428b 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -347,8 +347,8 @@ export const updateInstanceController: AppController = async (c) => { const createCashuWalletSchema = z.object({ description: z.string(), - relays: z.set(z.string().url()), - mints: z.set(z.string().url()).nonempty(), // must contain at least one item + relays: z.array(z.string().url()), + mints: z.array(z.string().url()).nonempty(), // must contain at least one item name: z.string(), }); @@ -370,7 +370,7 @@ export const createCashuWalletController: AppController = async (c) => { } const { description, relays, mints, name } = result.data; - relays.add(Conf.relay); + relays.push(Conf.relay); const tags: string[][] = []; @@ -379,11 +379,11 @@ export const createCashuWalletController: AppController = async (c) => { tags.push(['description', description]); tags.push(['unit', 'sat']); - for (const mint of mints) { + for (const mint of new Set(mints)) { tags.push(['mint', mint]); } - for (const relay of relays) { + for (const relay of new Set(relays)) { tags.push(['relay', relay]); } From c83a2dba7e99aefac0c4cc930c4a80dd4a08bac0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 20:39:51 -0600 Subject: [PATCH 42/47] Give requireSigner middleware the right type --- src/middleware/requireSigner.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/middleware/requireSigner.ts b/src/middleware/requireSigner.ts index c954dbd6..e360ab42 100644 --- a/src/middleware/requireSigner.ts +++ b/src/middleware/requireSigner.ts @@ -1,9 +1,9 @@ +import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; - -import { AppMiddleware } from '@/app.ts'; +import { NostrSigner } from '@nostrify/nostrify'; /** Throw a 401 if a signer isn't set. */ -export const requireSigner: AppMiddleware = async (c, next) => { +export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => { if (!c.get('signer')) { throw new HTTPException(401, { message: 'No pubkey provided' }); } From c3403ba724f3449d3b93cd417c09e4e0b6726382 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 21:03:42 -0600 Subject: [PATCH 43/47] Make AppController accept a path parameter --- src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 8bdeccf3..18b0fb96 100644 --- a/src/app.ts +++ b/src/app.ts @@ -166,7 +166,7 @@ export interface AppEnv extends HonoEnv { type AppContext = Context; type AppMiddleware = MiddlewareHandler; -type AppController = Handler>; +type AppController

= Handler>; const app = new Hono({ strict: false }); From 99d52f864081dd9fd97a7fae1542ce36824c4f89 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 21:16:38 -0600 Subject: [PATCH 44/47] Add local suggestions controller --- src/app.ts | 7 ++++++- src/controllers/api/suggestions.ts | 23 ++++++++++++++++++++++- src/utils/api.ts | 3 ++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 18b0fb96..6929757f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -109,7 +109,11 @@ import { zappedByController, } from '@/controllers/api/statuses.ts'; import { streamingController } from '@/controllers/api/streaming.ts'; -import { suggestionsV1Controller, suggestionsV2Controller } from '@/controllers/api/suggestions.ts'; +import { + localSuggestionsController, + suggestionsV1Controller, + suggestionsV2Controller, +} from '@/controllers/api/suggestions.ts'; import { hashtagTimelineController, homeTimelineController, @@ -348,6 +352,7 @@ app.get( app.get('/api/v1/suggestions', suggestionsV1Controller); app.get('/api/v2/suggestions', suggestionsV2Controller); +app.get('/api/v2/ditto/suggestions/local', localSuggestionsController); app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, notificationsController); app.get('/api/v1/notifications/:id', requireSigner, notificationController); diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index c047c415..e2357c1a 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -4,7 +4,7 @@ import { matchFilter } from 'nostr-tools'; import { AppContext, AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { paginatedList } from '@/utils/api.ts'; +import { paginated, paginatedList } from '@/utils/api.ts'; import { getTagSet } from '@/utils/tags.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; @@ -87,3 +87,24 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi }; })); } + +export const localSuggestionsController: AppController = async (c) => { + const signal = c.req.raw.signal; + const params = c.get('pagination'); + const store = c.get('store'); + + const events = await store.query( + [{ kinds: [0], search: `domain:${Conf.url.host}`, ...params }], + { signal }, + ) + .then((events) => hydrateEvents({ store, events, signal })); + + const suggestions = await Promise.all(events.map(async (event) => { + return { + source: 'global', + account: await renderAccount(event), + }; + })); + + return paginated(c, events, suggestions); +}; diff --git a/src/utils/api.ts b/src/utils/api.ts index 89cb608b..29304cbd 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -207,7 +207,8 @@ function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined return `<${next}>; rel="next", <${prev}>; rel="prev"`; } -type Entity = { id: string }; +// deno-lint-ignore ban-types +type Entity = {}; type HeaderRecord = Record; /** Return results with pagination headers. Assumes chronological sorting of events. */ From 2dfde337cd4ddab0bcc27930fd938e5a2010776a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 21:53:45 -0600 Subject: [PATCH 45/47] Fix localSuggestionsController --- src/controllers/api/suggestions.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index e2357c1a..c939b1ab 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -93,18 +93,34 @@ export const localSuggestionsController: AppController = async (c) => { const params = c.get('pagination'); const store = c.get('store'); - const events = await store.query( - [{ kinds: [0], search: `domain:${Conf.url.host}`, ...params }], + const grants = await store.query( + [{ kinds: [30360], authors: [Conf.pubkey], ...params }], + { signal }, + ); + + const pubkeys = new Set(); + + for (const grant of grants) { + const pubkey = grant.tags.find(([name]) => name === 'p')?.[1]; + if (pubkey) { + pubkeys.add(pubkey); + } + } + + const profiles = await store.query( + [{ kinds: [0], authors: [...pubkeys], search: `domain:${Conf.url.host}`, ...params }], { signal }, ) .then((events) => hydrateEvents({ store, events, signal })); - const suggestions = await Promise.all(events.map(async (event) => { + const suggestions = await Promise.all([...pubkeys].map(async (pubkey) => { + const profile = profiles.find((event) => event.pubkey === pubkey); + return { source: 'global', - account: await renderAccount(event), + account: profile ? await renderAccount(profile) : await accountFromPubkey(pubkey), }; })); - return paginated(c, events, suggestions); + return paginated(c, grants, suggestions); }; From b7a1efe33cd8921039426101709a2a9d6022d46e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Jan 2025 21:56:45 -0600 Subject: [PATCH 46/47] localSuggestionsController: skip accounts without a profile --- src/controllers/api/suggestions.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index c939b1ab..0c887b12 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -113,14 +113,15 @@ export const localSuggestionsController: AppController = async (c) => { ) .then((events) => hydrateEvents({ store, events, signal })); - const suggestions = await Promise.all([...pubkeys].map(async (pubkey) => { + const suggestions = (await Promise.all([...pubkeys].map(async (pubkey) => { const profile = profiles.find((event) => event.pubkey === pubkey); + if (!profile) return; return { source: 'global', - account: profile ? await renderAccount(profile) : await accountFromPubkey(pubkey), + account: await renderAccount(profile), }; - })); + }))).filter(Boolean); return paginated(c, grants, suggestions); }; From 640e533dca7e5c9bea96f073942d72db070baa92 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 1 Feb 2025 11:59:38 -0600 Subject: [PATCH 47/47] Add InternalRelay test --- src/controllers/nostr/relay.ts | 2 +- src/storages/InternalRelay.test.ts | 23 +++++++++++++++++++++++ src/storages/InternalRelay.ts | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 src/storages/InternalRelay.test.ts diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index c3bb2c8c..ac169adb 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -149,7 +149,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) { send(['EVENT', subId, msg[2]]); } } - } catch (_e) { + } catch { controllers.delete(subId); } } diff --git a/src/storages/InternalRelay.test.ts b/src/storages/InternalRelay.test.ts new file mode 100644 index 00000000..c97dcd39 --- /dev/null +++ b/src/storages/InternalRelay.test.ts @@ -0,0 +1,23 @@ +import { assertEquals } from '@std/assert'; + +import { eventFixture } from '@/test.ts'; + +import { InternalRelay } from './InternalRelay.ts'; + +Deno.test('InternalRelay', async () => { + const relay = new InternalRelay(); + const event1 = await eventFixture('event-1'); + + const promise = new Promise((resolve) => setTimeout(() => resolve(relay.event(event1)), 0)); + + for await (const msg of relay.req([{}])) { + if (msg[0] === 'EVENT') { + assertEquals(relay.subs.size, 1); + assertEquals(msg[2], event1); + break; + } + } + + await promise; + assertEquals(relay.subs.size, 0); // cleanup +}); diff --git a/src/storages/InternalRelay.ts b/src/storages/InternalRelay.ts index 4400b562..4f38c863 100644 --- a/src/storages/InternalRelay.ts +++ b/src/storages/InternalRelay.ts @@ -24,7 +24,7 @@ interface InternalRelayOpts { * The pipeline should push events to it, then anything in the application can subscribe to it. */ export class InternalRelay implements NRelay { - private subs = new Map }>(); + readonly subs = new Map }>(); constructor(private opts: InternalRelayOpts = {}) {}