Merge remote-tracking branch 'origin/main' into opengraph-metadata

This commit is contained in:
Alex Gleason 2024-08-07 18:19:13 -05:00
commit 349f59cc70
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
30 changed files with 486 additions and 301 deletions

View file

@ -30,7 +30,8 @@
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.29.0", "@nostrify/db": "jsr:@nostrify/db@^0.30.0",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.0",
"@scure/base": "npm:@scure/base@^1.1.6", "@scure/base": "npm:@scure/base@^1.1.6",
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
@ -64,7 +65,7 @@
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34", "nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
"nostr-tools": "npm:nostr-tools@2.5.1", "nostr-tools": "npm:nostr-tools@2.5.1",
"nostr-wasm": "npm:nostr-wasm@^0.1.0", "nostr-wasm": "npm:nostr-wasm@^0.1.0",
"path-to-regexp": "npm:path-to-regexp@6.2.1", "path-to-regexp": "npm:path-to-regexp@^7.1.0",
"postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js", "postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js",
"prom-client": "npm:prom-client@^15.1.2", "prom-client": "npm:prom-client@^15.1.2",
"question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts", "question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts",

34
deno.lock generated
View file

@ -11,10 +11,12 @@
"jsr:@gleasonator/policy@0.4.1": "jsr:@gleasonator/policy@0.4.1", "jsr:@gleasonator/policy@0.4.1": "jsr:@gleasonator/policy@0.4.1",
"jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.3", "jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.3",
"jsr:@lambdalisue/async@^2.1.1": "jsr:@lambdalisue/async@2.1.1", "jsr:@lambdalisue/async@^2.1.1": "jsr:@lambdalisue/async@2.1.1",
"jsr:@nostrify/db@^0.30.0": "jsr:@nostrify/db@0.30.0",
"jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5", "jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5",
"jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4", "jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4",
"jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5", "jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5",
"jsr:@nostrify/nostrify@^0.29.0": "jsr:@nostrify/nostrify@0.29.0", "jsr:@nostrify/nostrify@^0.30.0": "jsr:@nostrify/nostrify@0.30.0",
"jsr:@nostrify/types@^0.30.0": "jsr:@nostrify/types@0.30.0",
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0",
"jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0", "jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0",
"jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0", "jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0",
@ -71,8 +73,7 @@
"npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1", "npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1",
"npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0", "npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0",
"npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.0", "npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.0",
"npm:path-to-regexp@6.2.1": "npm:path-to-regexp@6.2.1", "npm:path-to-regexp@^7.1.0": "npm:path-to-regexp@7.1.0",
"npm:path-to-regexp@7.1.0": "npm:path-to-regexp@7.1.0",
"npm:postgres@3.4.4": "npm:postgres@3.4.4", "npm:postgres@3.4.4": "npm:postgres@3.4.4",
"npm:prom-client@^15.1.2": "npm:prom-client@15.1.2", "npm:prom-client@^15.1.2": "npm:prom-client@15.1.2",
"npm:tldts@^6.0.14": "npm:tldts@6.1.18", "npm:tldts@^6.0.14": "npm:tldts@6.1.18",
@ -137,6 +138,15 @@
"@lambdalisue/async@2.1.1": { "@lambdalisue/async@2.1.1": {
"integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4" "integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4"
}, },
"@nostrify/db@0.30.0": {
"integrity": "a75ba78be89d57c54c3d47e9e94c7142817c5b50daec27bf7f9a4af4629be20b",
"dependencies": [
"jsr:@nostrify/nostrify@^0.30.0",
"jsr:@nostrify/types@^0.30.0",
"npm:kysely@^0.27.3",
"npm:nostr-tools@^2.7.0"
]
},
"@nostrify/nostrify@0.22.4": { "@nostrify/nostrify@0.22.4": {
"integrity": "1c8a7847e5773213044b491e85fd7cafae2ad194ce59da4d957d2b27c776b42d", "integrity": "1c8a7847e5773213044b491e85fd7cafae2ad194ce59da4d957d2b27c776b42d",
"dependencies": [ "dependencies": [
@ -166,21 +176,24 @@
"npm:zod@^3.23.8" "npm:zod@^3.23.8"
] ]
}, },
"@nostrify/nostrify@0.29.0": { "@nostrify/nostrify@0.30.0": {
"integrity": "d0489b62441c891324cce60c14bb398013259494b5ad9d21ec6dfbf0ca7368c9", "integrity": "7c29e7d8b5a0a81e238170ac1e7ad708bc72dd8f478d8d82c30598fb4eff9b9c",
"dependencies": [ "dependencies": [
"jsr:@nostrify/types@^0.30.0",
"jsr:@std/crypto@^0.224.0", "jsr:@std/crypto@^0.224.0",
"jsr:@std/encoding@^0.224.1", "jsr:@std/encoding@^0.224.1",
"npm:@scure/base@^1.1.6", "npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.4.0", "npm:@scure/bip32@^1.4.0",
"npm:@scure/bip39@^1.3.0", "npm:@scure/bip39@^1.3.0",
"npm:kysely@^0.27.3",
"npm:lru-cache@^10.2.0", "npm:lru-cache@^10.2.0",
"npm:nostr-tools@^2.7.0", "npm:nostr-tools@^2.7.0",
"npm:websocket-ts@^2.1.5", "npm:websocket-ts@^2.1.5",
"npm:zod@^3.23.8" "npm:zod@^3.23.8"
] ]
}, },
"@nostrify/types@0.30.0": {
"integrity": "1f38fa849cff930bd709edbf94ef9ac02f46afb8b851f86c8736517b354616da"
},
"@soapbox/kysely-deno-sqlite@2.2.0": { "@soapbox/kysely-deno-sqlite@2.2.0": {
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
"dependencies": [ "dependencies": [
@ -935,10 +948,6 @@
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dependencies": {} "dependencies": {}
}, },
"path-to-regexp@6.2.1": {
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==",
"dependencies": {}
},
"path-to-regexp@7.1.0": { "path-to-regexp@7.1.0": {
"integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==", "integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==",
"dependencies": {} "dependencies": {}
@ -1818,7 +1827,8 @@
"jsr:@db/sqlite@^0.11.1", "jsr:@db/sqlite@^0.11.1",
"jsr:@hono/hono@^4.4.6", "jsr:@hono/hono@^4.4.6",
"jsr:@lambdalisue/async@^2.1.1", "jsr:@lambdalisue/async@^2.1.1",
"jsr:@nostrify/nostrify@^0.29.0", "jsr:@nostrify/db@^0.30.0",
"jsr:@nostrify/nostrify@^0.30.0",
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
"jsr:@soapbox/stickynotes@^0.4.0", "jsr:@soapbox/stickynotes@^0.4.0",
"jsr:@std/assert@^0.225.1", "jsr:@std/assert@^0.225.1",
@ -1851,7 +1861,7 @@
"npm:nostr-relaypool2@0.6.34", "npm:nostr-relaypool2@0.6.34",
"npm:nostr-tools@2.5.1", "npm:nostr-tools@2.5.1",
"npm:nostr-wasm@^0.1.0", "npm:nostr-wasm@^0.1.0",
"npm:path-to-regexp@6.2.1", "npm:path-to-regexp@^7.1.0",
"npm:prom-client@^15.1.2", "npm:prom-client@^15.1.2",
"npm:tldts@^6.0.14", "npm:tldts@^6.0.14",
"npm:tseep@^1.2.1", "npm:tseep@^1.2.1",

View file

@ -40,8 +40,10 @@ import {
adminRelaysController, adminRelaysController,
adminSetRelaysController, adminSetRelaysController,
deleteZapSplitsController, deleteZapSplitsController,
getZapSplitsController,
nameRequestController, nameRequestController,
nameRequestsController, nameRequestsController,
statusZapSplitsController,
updateZapSplitsController, updateZapSplitsController,
} from '@/controllers/api/ditto.ts'; } from '@/controllers/api/ditto.ts';
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
@ -117,6 +119,7 @@ import { nostrController } from '@/controllers/well-known/nostr.ts';
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts'; import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
import { requireSigner } from '@/middleware/requireSigner.ts'; import { requireSigner } from '@/middleware/requireSigner.ts';
import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
@ -131,8 +134,12 @@ interface AppEnv extends HonoEnv {
uploader?: NUploader; uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */ /** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent; proof?: NostrEvent;
/** Store */ /** Storage for the user, might filter out unwanted content. */
store: NStore; store: NStore;
/** Normalized pagination params. */
pagination: { since?: number; until?: number; limit: number };
/** Normalized list pagination params. */
listPagination: { offset: number; limit: number };
}; };
} }
@ -146,7 +153,7 @@ const debug = Debug('ditto:http');
app.use('*', rateLimitMiddleware(300, Time.minutes(5))); app.use('*', rateLimitMiddleware(300, Time.minutes(5)));
app.use('/api/*', metricsMiddleware, logger(debug)); app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug));
app.use('/.well-known/*', metricsMiddleware, logger(debug)); app.use('/.well-known/*', metricsMiddleware, logger(debug));
app.use('/users/*', metricsMiddleware, logger(debug)); app.use('/users/*', metricsMiddleware, logger(debug));
app.use('/nodeinfo/*', metricsMiddleware, logger(debug)); app.use('/nodeinfo/*', metricsMiddleware, logger(debug));
@ -264,6 +271,9 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro
app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
app.get('/api/v1/ditto/zap_splits', 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); app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController);
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController);

View file

@ -9,8 +9,8 @@ import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { uploadFile } from '@/utils/upload.ts'; import { uploadFile } from '@/utils/upload.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts';
import { lookupAccount } from '@/utils/lookup.ts'; import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts';
import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts';
@ -110,45 +110,38 @@ const accountSearchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent), q: z.string().transform(decodeURIComponent),
resolve: booleanParamSchema.optional().transform(Boolean), resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false), following: z.boolean().default(false),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
}); });
const accountSearchController: AppController = async (c) => { const accountSearchController: AppController = async (c) => {
const result = accountSearchQuerySchema.safeParse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
const { limit } = c.get('pagination');
const result = accountSearchQuerySchema.safeParse(c.req.query());
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422); return c.json({ error: 'Bad request', schema: result.error }, 422);
} }
const { q, limit } = result.data; const query = decodeURIComponent(result.data.q);
const query = decodeURIComponent(q);
const store = await Storages.search(); const store = await Storages.search();
const [event, events] = await Promise.all([ const lookup = extractIdentifier(query);
lookupAccount(query), const event = await lookupAccount(lookup ?? query);
store.query([{ kinds: [0], search: query, limit }], { signal }),
]);
const results = await hydrateEvents({ if (!event && lookup) {
events: event ? [event, ...events] : events, const pubkey = await lookupPubkey(lookup);
store, return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []);
signal,
});
if ((results.length < 1) && query.match(/npub1\w+/)) {
const possibleNpub = query;
try {
const npubHex = nip19.decode(possibleNpub);
return c.json([await accountFromPubkey(String(npubHex.data))]);
} catch (e) {
console.log(e);
return c.json([]);
}
} }
const accounts = await Promise.all(results.map((event) => renderAccount(event))); const events = event ? [event] : await store.query([{ kinds: [0], search: query, limit }], { signal });
const accounts = await hydrateEvents({ events, store, signal }).then(
(events) =>
Promise.all(
events.map((event) => renderAccount(event)),
),
);
return c.json(accounts); return c.json(accounts);
}; };
@ -160,7 +153,25 @@ const relationshipsController: AppController = async (c) => {
return c.json({ error: 'Missing `id[]` query parameters.' }, 422); return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
} }
const result = await Promise.all(ids.data.map((id) => renderRelationship(pubkey, id))); const db = await Storages.db();
const [sourceEvents, targetEvents] = await Promise.all([
db.query([{ kinds: [3, 10000], authors: [pubkey] }]),
db.query([{ kinds: [3], authors: ids.data }]),
]);
const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey);
const event10000 = sourceEvents.find((event) => event.kind === 10000 && event.pubkey === pubkey);
const result = ids.data.map((id) =>
renderRelationship({
sourcePubkey: pubkey,
targetPubkey: id,
event3,
target3: targetEvents.find((event) => event.kind === 3 && event.pubkey === id),
event10000,
})
);
return c.json(result); return c.json(result);
}; };
@ -174,7 +185,7 @@ const accountStatusesQuerySchema = z.object({
const accountStatusesController: AppController = async (c) => { const accountStatusesController: AppController = async (c) => {
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const { since, until } = paginationSchema.parse(c.req.query()); const { since, until } = c.get('pagination');
const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query()); const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -325,7 +336,7 @@ const followController: AppController = async (c) => {
c, c,
); );
const relationship = await renderRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(sourcePubkey, targetPubkey);
relationship.following = true; relationship.following = true;
return c.json(relationship); return c.json(relationship);
@ -342,13 +353,13 @@ const unfollowController: AppController = async (c) => {
c, c,
); );
const relationship = await renderRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
const followersController: AppController = (c) => { const followersController: AppController = (c) => {
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
return renderEventAccounts(c, [{ kinds: [3], '#p': [pubkey], ...params }]); return renderEventAccounts(c, [{ kinds: [3], '#p': [pubkey], ...params }]);
}; };
@ -379,7 +390,7 @@ const muteController: AppController = async (c) => {
c, c,
); );
const relationship = await renderRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
@ -394,13 +405,13 @@ const unmuteController: AppController = async (c) => {
c, c,
); );
const relationship = await renderRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
const favouritesController: AppController = async (c) => { const favouritesController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const { signal } = c.req.raw; const { signal } = c.req.raw;
const store = await Storages.db(); const store = await Storages.db();
@ -447,6 +458,23 @@ const familiarFollowersController: AppController = async (c) => {
return c.json(results); return c.json(results);
}; };
async function getRelationship(sourcePubkey: string, targetPubkey: string) {
const db = await Storages.db();
const [sourceEvents, targetEvents] = await Promise.all([
db.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]),
db.query([{ kinds: [3], authors: [targetPubkey] }]),
]);
return renderRelationship({
sourcePubkey,
targetPubkey,
event3: sourceEvents.find((event) => event.kind === 3 && event.pubkey === sourcePubkey),
target3: targetEvents.find((event) => event.kind === 3 && event.pubkey === targetPubkey),
event10000: sourceEvents.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey),
});
}
export { export {
accountController, accountController,
accountLookupController, accountLookupController,

View file

@ -6,7 +6,7 @@ import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
import { renderNameRequest } from '@/views/ditto.ts'; import { renderNameRequest } from '@/views/ditto.ts';
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
@ -29,7 +29,7 @@ const adminAccountQuerySchema = z.object({
const adminAccountsController: AppController = async (c) => { const adminAccountsController: AppController = async (c) => {
const store = await Storages.db(); const store = await Storages.db();
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const { signal } = c.req.raw; const { signal } = c.req.raw;
const { const {
local, local,

View file

@ -1,4 +1,7 @@
import { z } from 'zod';
import type { AppController } from '@/app.ts'; import type { AppController } from '@/app.ts';
import { parseBody } from '@/utils/api.ts';
/** /**
* Apps are unnecessary cruft in Mastodon API, but necessary to make clients work. * Apps are unnecessary cruft in Mastodon API, but necessary to make clients work.
@ -14,10 +17,14 @@ const FAKE_APP = {
vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
}; };
const createAppSchema = z.object({
redirect_uris: z.string().url().optional(),
});
const createAppController: AppController = async (c) => { const createAppController: AppController = async (c) => {
// TODO: Handle both formData and json. 422 on parsing error. // TODO: Handle both formData and json. 422 on parsing error.
try { try {
const { redirect_uris } = await c.req.json(); const { redirect_uris } = createAppSchema.parse(await parseBody(c.req.raw));
return c.json({ return c.json({
...FAKE_APP, ...FAKE_APP,

View file

@ -1,18 +1,22 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { booleanParamSchema } from '@/schema.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts';
import { renderNameRequest } from '@/views/ditto.ts';
import { getZapSplits } from '@/utils/zap-split.ts';
import { updateListAdminEvent } from '@/utils/api.ts';
import { addTag } from '@/utils/tags.ts'; import { addTag } from '@/utils/tags.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { booleanParamSchema, percentageSchema } from '@/schema.ts';
import { Conf } from '@/config.ts';
import { createEvent, paginated, parseBody } from '@/utils/api.ts';
import { deleteTag } from '@/utils/tags.ts'; import { deleteTag } from '@/utils/tags.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
import { getAuthor } from '@/queries.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderNameRequest } from '@/views/ditto.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts';
import { Storages } from '@/storages.ts';
import { updateListAdminEvent } from '@/utils/api.ts';
const markerSchema = z.enum(['read', 'write']); const markerSchema = z.enum(['read', 'write']);
@ -111,7 +115,7 @@ export const nameRequestsController: AppController = async (c) => {
const signer = c.get('signer')!; const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
const filter: NostrFilter = { const filter: NostrFilter = {
@ -156,7 +160,7 @@ export const nameRequestsController: AppController = async (c) => {
const zapSplitSchema = z.record( const zapSplitSchema = z.record(
n.id(), n.id(),
z.object({ z.object({
amount: z.number().int().min(1).max(100), weight: z.number().int().min(1).max(100),
message: z.string().max(500), message: z.string().max(500),
}), }),
); );
@ -170,8 +174,8 @@ export const updateZapSplitsController: AppController = async (c) => {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
const zap_split = await getZapSplits(store, Conf.pubkey); const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
if (!zap_split) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
@ -186,7 +190,7 @@ export const updateZapSplitsController: AppController = async (c) => {
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, { kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) => (tags) =>
pubkeys.reduce((accumulator, pubkey) => { pubkeys.reduce((accumulator, pubkey) => {
return addTag(accumulator, ['p', pubkey, data[pubkey].amount.toString(), data[pubkey].message]); return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]);
}, tags), }, tags),
c, c,
); );
@ -205,8 +209,8 @@ export const deleteZapSplitsController: AppController = async (c) => {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
const zap_split = await getZapSplits(store, Conf.pubkey); const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
if (!zap_split) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
@ -223,3 +227,61 @@ export const deleteZapSplitsController: AppController = async (c) => {
return c.json(200); return c.json(200);
}; };
export const getZapSplitsController: AppController = async (c) => {
const store = c.get('store');
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {};
if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
}
const pubkeys = Object.keys(dittoZapSplit);
const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => {
const author = await getAuthor(pubkey);
const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey);
return {
account,
weight: dittoZapSplit[pubkey].weight,
message: dittoZapSplit[pubkey].message,
};
}));
return c.json(zapSplits, 200);
};
export const statusZapSplitsController: AppController = async (c) => {
const store = c.get('store');
const id = c.req.param('id');
const { signal } = c.req.raw;
const [event] = await store.query([{ kinds: [1], ids: [id], limit: 1 }], { signal });
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
const zapsTag = event.tags.filter(([name]) => name === 'zap');
const pubkeys = zapsTag.map((name) => name[1]);
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
await hydrateEvents({ events: users, store, signal });
const zapSplits = (await Promise.all(pubkeys.map(async (pubkey) => {
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey);
const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0;
return {
account,
message: '',
weight: weight,
};
}))).filter((zapSplit) => zapSplit.weight > 0);
return c.json(zapSplits, 200);
};

View file

@ -4,16 +4,12 @@ import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`; const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
const instanceV1Controller: AppController = async (c) => { const instanceV1Controller: AppController = async (c) => {
const { host, protocol } = Conf.url; const { host, protocol } = Conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
const store = c.get('store');
const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {};
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -72,9 +68,6 @@ const instanceV1Controller: AppController = async (c) => {
}, },
}, },
rules: [], rules: [],
ditto: {
zap_split,
},
}); });
}; };

View file

@ -3,8 +3,9 @@ import { z } from 'zod';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoPagination } from '@/interfaces/DittoPagination.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts'; import { paginated } from '@/utils/api.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts';
/** Set of known notification types across backends. */ /** Set of known notification types across backends. */
@ -30,7 +31,7 @@ const notificationsSchema = z.object({
const notificationsController: AppController = async (c) => { const notificationsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const types = notificationTypes const types = notificationTypes
.intersection(new Set(c.req.queries('types[]') ?? notificationTypes)) .intersection(new Set(c.req.queries('types[]') ?? notificationTypes))
@ -72,7 +73,7 @@ const notificationsController: AppController = async (c) => {
async function renderNotifications( async function renderNotifications(
filters: NostrFilter[], filters: NostrFilter[],
types: Set<string>, types: Set<string>,
params: PaginationParams, params: DittoPagination,
c: AppContext, c: AppContext,
) { ) {
const store = c.get('store'); const store = c.get('store');

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { createEvent, paginated, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts'; import { createEvent, paginated, parseBody, updateEventInfo } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts'; import { renderAdminReport } from '@/views/mastodon/reports.ts';
import { renderReport } from '@/views/mastodon/reports.ts'; import { renderReport } from '@/views/mastodon/reports.ts';
@ -64,7 +64,7 @@ const adminReportsController: AppController = async (c) => {
const store = c.get('store'); const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await c.get('signer')?.getPublicKey();
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query()); const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
const filter: NostrFilter = { const filter: NostrFilter = {

View file

@ -5,14 +5,11 @@ import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
/** Matches NIP-05 names with or without an @ in front. */
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
const searchQuerySchema = z.object({ const searchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent), q: z.string().transform(decodeURIComponent),
@ -33,43 +30,44 @@ const searchController: AppController = async (c) => {
return c.json({ error: 'Bad request', schema: result.error }, 422); return c.json({ error: 'Bad request', schema: result.error }, 422);
} }
const [event, events] = await Promise.all([ const event = await lookupEvent(result.data, signal);
lookupEvent(result.data, signal), const lookup = extractIdentifier(result.data.q);
searchEvents(result.data, signal),
]);
if (event) { // Render account from pubkey.
events.push(event); if (!event && lookup) {
const pubkey = await lookupPubkey(lookup);
return c.json({
accounts: pubkey ? [await accountFromPubkey(pubkey)] : [],
statuses: [],
hashtags: [],
});
}
let events: NostrEvent[] = [];
if (event) {
events = [event];
} else {
events = await searchEvents(result.data, signal);
} }
const results = dedupeEvents(events);
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await c.get('signer')?.getPublicKey();
const [accounts, statuses] = await Promise.all([ const [accounts, statuses] = await Promise.all([
Promise.all( Promise.all(
results events
.filter((event) => event.kind === 0) .filter((event) => event.kind === 0)
.map((event) => renderAccount(event)) .map((event) => renderAccount(event))
.filter(Boolean), .filter(Boolean),
), ),
Promise.all( Promise.all(
results events
.filter((event) => event.kind === 1) .filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey })) .map((event) => renderStatus(event, { viewerPubkey }))
.filter(Boolean), .filter(Boolean),
), ),
]); ]);
if ((result.data.type === 'accounts') && (accounts.length < 1) && (result.data.q.match(/npub1\w+/))) {
const possibleNpub = result.data.q;
try {
const npubHex = nip19.decode(possibleNpub);
accounts.push(await accountFromPubkey(String(npubHex.data)));
} catch (e) {
console.log(e);
}
}
return c.json({ return c.json({
accounts, accounts,
statuses, statuses,
@ -121,54 +119,55 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Nos
/** Get filters to lookup the input value. */ /** Get filters to lookup the input value. */
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> { async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> {
const filters: NostrFilter[] = [];
const accounts = !type || type === 'accounts'; const accounts = !type || type === 'accounts';
const statuses = !type || type === 'statuses'; const statuses = !type || type === 'statuses';
if (!resolve || type === 'hashtags') { if (!resolve || type === 'hashtags') {
return [];
}
if (n.id().safeParse(q).success) {
const filters: NostrFilter[] = [];
if (accounts) filters.push({ kinds: [0], authors: [q] });
if (statuses) filters.push({ kinds: [1], ids: [q] });
return filters; return filters;
} }
if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) { const lookup = extractIdentifier(q);
try { if (!lookup) return [];
const result = nip19.decode(q);
switch (result.type) { try {
case 'npub': const result = nip19.decode(lookup);
if (accounts) filters.push({ kinds: [0], authors: [result.data] }); const filters: NostrFilter[] = [];
break; switch (result.type) {
case 'nprofile': case 'npub':
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); if (accounts) filters.push({ kinds: [0], authors: [result.data] });
break; break;
case 'note': case 'nprofile':
if (statuses) { if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
filters.push({ kinds: [1], ids: [result.data] }); break;
} case 'note':
break; if (statuses) filters.push({ kinds: [1], ids: [result.data] });
case 'nevent': break;
if (statuses) { case 'nevent':
filters.push({ kinds: [1], ids: [result.data.id] }); if (statuses) filters.push({ kinds: [1], ids: [result.data.id] });
} break;
break;
}
} catch (_e) {
// do nothing
}
} else if (/^[0-9a-f]{64}$/.test(q)) {
if (accounts) filters.push({ kinds: [0], authors: [q] });
if (statuses) filters.push({ kinds: [1], ids: [q] });
} else if (accounts && ACCT_REGEX.test(q)) {
try {
const { pubkey } = await nip05Cache.fetch(q, { signal });
if (pubkey) {
filters.push({ kinds: [0], authors: [pubkey] });
}
} catch (_e) {
// do nothing
} }
return filters;
} catch {
// do nothing
} }
return filters; try {
const { pubkey } = await nip05Cache.fetch(lookup, { signal });
if (pubkey) {
return [{ kinds: [0], authors: [pubkey] }];
}
} catch {
// do nothing
}
return [];
} }
export { searchController }; export { searchController };

View file

@ -19,15 +19,7 @@ import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts';
import { import { createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts';
createEvent,
listPaginationSchema,
paginated,
paginatedList,
paginationSchema,
parseBody,
updateListEvent,
} from '@/utils/api.ts';
import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
import { getZapSplits } from '@/utils/zap-split.ts'; import { getZapSplits } from '@/utils/zap-split.ts';
@ -179,12 +171,12 @@ const createStatusController: AppController = async (c) => {
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta); const lnurl = getLnurl(meta);
const zap_split = await getZapSplits(store, Conf.pubkey); const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
if (lnurl && zap_split) { if (lnurl && dittoZapSplit) {
let totalSplit = 0; let totalSplit = 0;
for (const pubkey in zap_split) { for (const pubkey in dittoZapSplit) {
totalSplit += zap_split[pubkey].amount; totalSplit += dittoZapSplit[pubkey].weight;
tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.toString()]); tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString()]);
} }
if (totalSplit) { if (totalSplit) {
tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]); tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
@ -296,7 +288,7 @@ const favouriteController: AppController = async (c) => {
const favouritedByController: AppController = (c) => { const favouritedByController: AppController = (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
return renderEventAccounts(c, [{ kinds: [7], '#e': [id], ...params }], { return renderEventAccounts(c, [{ kinds: [7], '#e': [id], ...params }], {
filterFn: ({ content }) => content === '+', filterFn: ({ content }) => content === '+',
@ -364,13 +356,13 @@ const unreblogStatusController: AppController = async (c) => {
const rebloggedByController: AppController = (c) => { const rebloggedByController: AppController = (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]); return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]);
}; };
const quotesController: AppController = async (c) => { const quotesController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const store = await Storages.db(); const store = await Storages.db();
const [event] = await store.query([{ ids: [id], kinds: [1] }]); const [event] = await store.query([{ ids: [id], kinds: [1] }]);
@ -571,7 +563,7 @@ const zapController: AppController = async (c) => {
const zappedByController: AppController = async (c) => { const zappedByController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = listPaginationSchema.parse(c.req.query()); const params = c.get('listPagination');
const store = await Storages.db(); const store = await Storages.db();
const db = await DittoDB.getInstance(); const db = await DittoDB.getInstance();

View file

@ -4,13 +4,13 @@ import { matchFilter } from 'nostr-tools';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { listPaginationSchema, paginatedList, PaginatedListParams } from '@/utils/api.ts'; import { paginatedList } from '@/utils/api.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV1Controller: AppController = async (c) => {
const signal = c.req.raw.signal; const signal = c.req.raw.signal;
const params = listPaginationSchema.parse(c.req.query()); const params = c.get('listPagination');
const suggestions = await renderV2Suggestions(c, params, signal); const suggestions = await renderV2Suggestions(c, params, signal);
const accounts = suggestions.map(({ account }) => account); const accounts = suggestions.map(({ account }) => account);
return paginatedList(c, params, accounts); return paginatedList(c, params, accounts);
@ -18,12 +18,12 @@ export const suggestionsV1Controller: AppController = async (c) => {
export const suggestionsV2Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => {
const signal = c.req.raw.signal; const signal = c.req.raw.signal;
const params = listPaginationSchema.parse(c.req.query()); const params = c.get('listPagination');
const suggestions = await renderV2Suggestions(c, params, signal); const suggestions = await renderV2Suggestions(c, params, signal);
return paginatedList(c, params, suggestions); return paginatedList(c, params, suggestions);
}; };
async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, signal?: AbortSignal) { async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) {
const { offset, limit } = params; const { offset, limit } = params;
const store = c.get('store'); const store = c.get('store');

View file

@ -6,12 +6,12 @@ import { Conf } from '@/config.ts';
import { getFeedPubkeys } from '@/queries.ts'; import { getFeedPubkeys } from '@/queries.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginationSchema } from '@/utils/api.ts'; import { paginated } from '@/utils/api.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
const homeTimelineController: AppController = async (c) => { const homeTimelineController: AppController = async (c) => {
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const authors = await getFeedPubkeys(pubkey); const authors = await getFeedPubkeys(pubkey);
return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]); return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]);
@ -23,7 +23,7 @@ const publicQuerySchema = z.object({
}); });
const publicTimelineController: AppController = (c) => { const publicTimelineController: AppController = (c) => {
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const { local, instance } = publicQuerySchema.parse(c.req.query()); const { local, instance } = publicQuerySchema.parse(c.req.query());
const filter: NostrFilter = { kinds: [1], ...params }; const filter: NostrFilter = { kinds: [1], ...params };
@ -39,13 +39,13 @@ const publicTimelineController: AppController = (c) => {
const hashtagTimelineController: AppController = (c) => { const hashtagTimelineController: AppController = (c) => {
const hashtag = c.req.param('hashtag')!.toLowerCase(); const hashtag = c.req.param('hashtag')!.toLowerCase();
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]); return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]);
}; };
const suggestedTimelineController: AppController = async (c) => { const suggestedTimelineController: AppController = async (c) => {
const store = c.get('store'); const store = c.get('store');
const params = paginationSchema.parse(c.req.query()); const params = c.get('pagination');
const [follows] = await store.query( const [follows] = await store.query(
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }], [{ kinds: [3], authors: [Conf.pubkey], limit: 1 }],

View file

@ -36,6 +36,7 @@ export interface MastodonAccount {
}; };
}; };
statuses_count: number; statuses_count: number;
uri: string;
url: string; url: string;
username: string; username: string;
ditto: { ditto: {

View file

@ -0,0 +1,15 @@
/** Based on Mastodon pagination. */
export interface DittoPagination {
/** Lowest Nostr event `created_at` timestamp. */
since?: number;
/** Highest Nostr event `created_at` timestamp. */
until?: number;
/** @deprecated Mastodon apps are supposed to use the `Link` header. */
max_id?: string;
/** @deprecated Mastodon apps are supposed to use the `Link` header. */
min_id?: string;
/** Maximum number of results to return. Default 20, maximum 40. */
limit?: number;
/** Used by Ditto to offset tag values in Nostr list events. */
offset?: number;
}

View file

@ -0,0 +1,49 @@
import { AppMiddleware } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts';
import { Storages } from '@/storages.ts';
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
export const paginationMiddleware: AppMiddleware = async (c, next) => {
const pagination = paginationSchema.parse(c.req.query());
const {
max_id: maxId,
min_id: minId,
since,
until,
} = pagination;
if ((maxId && !until) || (minId && !since)) {
const ids: string[] = [];
if (maxId) ids.push(maxId);
if (minId) ids.push(minId);
if (ids.length) {
const store = await Storages.db();
const events = await store.query(
[{ ids, limit: ids.length }],
{ signal: c.req.raw.signal },
);
for (const event of events) {
if (!until && maxId === event.id) pagination.until = event.created_at;
if (!since && minId === event.id) pagination.since = event.created_at;
}
}
}
c.set('pagination', {
since: pagination.since,
until: pagination.until,
limit: pagination.limit,
});
c.set('listPagination', {
limit: pagination.limit,
offset: pagination.offset,
});
await next();
};

11
src/schemas/pagination.ts Normal file
View file

@ -0,0 +1,11 @@
import { z } from 'zod';
/** Schema to parse pagination query params. */
export const paginationSchema = z.object({
max_id: z.string().optional().catch(undefined),
min_id: z.string().optional().catch(undefined),
since: z.coerce.number().nonnegative().optional().catch(undefined),
until: z.coerce.number().nonnegative().optional().catch(undefined),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
offset: z.coerce.number().nonnegative().catch(0),
});

View file

@ -1,7 +1,7 @@
// deno-lint-ignore-file require-await // deno-lint-ignore-file require-await
import { NDatabase } from '@nostrify/db';
import { import {
NDatabase,
NIP50, NIP50,
NKinds, NKinds,
NostrEvent, NostrEvent,

View file

@ -2,9 +2,10 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { Database as Sqlite } from '@db/sqlite'; import { Database as Sqlite } from '@db/sqlite';
import { NDatabase } from '@nostrify/db';
import { NostrEvent } from '@nostrify/nostrify';
import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite'; import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite';
import { finalizeEvent, generateSecretKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey } from 'nostr-tools';
import { NDatabase, NostrEvent } from '@nostrify/nostrify';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js'; import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';

View file

@ -19,7 +19,7 @@ function bech32ToPubkey(bech32: string): string | undefined {
case 'npub': case 'npub':
return decoded.data; return decoded.data;
} }
} catch (_) { } catch {
// //
} }
} }
@ -78,11 +78,6 @@ async function sha256(message: string): Promise<string> {
return hashHex; return hashHex;
} }
/** Deduplicate events by ID. */
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
return [...new Map(events.map((event) => [event.id, event])).values()];
}
/** Test whether the value is a Nostr ID. */ /** Test whether the value is a Nostr ID. */
function isNostrId(value: unknown): boolean { function isNostrId(value: unknown): boolean {
return n.id().safeParse(value).success; return n.id().safeParse(value).success;
@ -93,18 +88,6 @@ function isURL(value: unknown): boolean {
return z.string().url().safeParse(value).success; return z.string().url().safeParse(value).success;
} }
export { export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 };
bech32ToPubkey,
dedupeEvents,
eventAge,
findTag,
isNostrId,
isURL,
type Nip05,
nostrDate,
nostrNow,
parseNip05,
sha256,
};
export { Time } from '@/utils/time.ts'; export { Time } from '@/utils/time.ts';

View file

@ -5,7 +5,6 @@ import Debug from '@soapbox/stickynotes/debug';
import { parseFormData } from 'formdata-helper'; import { parseFormData } from 'formdata-helper';
import { EventTemplate } from 'nostr-tools'; import { EventTemplate } from 'nostr-tools';
import * as TypeFest from 'type-fest'; import * as TypeFest from 'type-fest';
import { z } from 'zod';
import { type AppContext } from '@/app.ts'; import { type AppContext } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
@ -176,16 +175,6 @@ async function parseBody(req: Request): Promise<unknown> {
} }
} }
/** Schema to parse pagination query params. */
const paginationSchema = z.object({
since: z.coerce.number().nonnegative().optional().catch(undefined),
until: z.coerce.number().nonnegative().optional().catch(undefined),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
});
/** Mastodon API pagination query params. */
type PaginationParams = z.infer<typeof paginationSchema>;
/** Build HTTP Link header for Mastodon API pagination. */ /** Build HTTP Link header for Mastodon API pagination. */
function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined {
if (events.length <= 1) return; if (events.length <= 1) return;
@ -219,12 +208,6 @@ function paginated(c: AppContext, events: NostrEvent[], entities: (Entity | unde
return c.json(results, 200, headers); return c.json(results, 200, headers);
} }
/** Query params for paginating a list. */
const listPaginationSchema = z.object({
offset: z.coerce.number().nonnegative().catch(0),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
});
/** Build HTTP Link header for paginating Nostr lists. */ /** Build HTTP Link header for paginating Nostr lists. */
function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined { function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined {
const { origin } = Conf.url; const { origin } = Conf.url;
@ -242,15 +225,10 @@ function buildListLinkHeader(url: string, params: { offset: number; limit: numbe
return `<${next}>; rel="next", <${prev}>; rel="prev"`; return `<${next}>; rel="next", <${prev}>; rel="prev"`;
} }
interface PaginatedListParams {
offset: number;
limit: number;
}
/** paginate a list of tags. */ /** paginate a list of tags. */
function paginatedList( function paginatedList(
c: AppContext, c: AppContext,
params: PaginatedListParams, params: { offset: number; limit: number },
entities: unknown[], entities: unknown[],
headers: HeaderRecord = {}, headers: HeaderRecord = {},
) { ) {
@ -296,13 +274,9 @@ export {
createAdminEvent, createAdminEvent,
createEvent, createEvent,
type EventStub, type EventStub,
listPaginationSchema,
localRequest, localRequest,
paginated, paginated,
paginatedList, paginatedList,
type PaginatedListParams,
type PaginationParams,
paginationSchema,
parseBody, parseBody,
updateAdminEvent, updateAdminEvent,
updateEvent, updateEvent,

View file

@ -1,4 +1,6 @@
import { NIP05, NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NIP05, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { match } from 'path-to-regexp';
import { getAuthor } from '@/queries.ts'; import { getAuthor } from '@/queries.ts';
import { bech32ToPubkey } from '@/utils.ts'; import { bech32ToPubkey } from '@/utils.ts';
@ -35,3 +37,54 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise
} }
} }
} }
/** Extract an acct or bech32 identifier out of a URL or of itself. */
export function extractIdentifier(value: string): string | undefined {
value = value.trim();
try {
const uri = new URL(value);
switch (uri.protocol) {
// Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`.
case 'nostr:':
value = uri.pathname;
break;
// Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`.
case 'http:':
case 'https:': {
const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname);
const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname);
const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname);
const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname);
const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname);
const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname);
if (accountUriMatch) {
value = accountUriMatch.params.acct;
} else if (accountUrlMatch) {
value = accountUrlMatch.params.acct;
} else if (statusUriMatch) {
value = nip19.noteEncode(statusUriMatch.params.id);
} else if (statusUrlMatch) {
value = nip19.noteEncode(statusUrlMatch.params.id);
} else if (soapboxMatch) {
value = nip19.noteEncode(soapboxMatch.params.id);
} else if (nostrMatch) {
value = nostrMatch.params.bech32;
}
break;
}
}
} catch {
// do nothing
}
value = value.replace(/^@/, '');
if (n.bech32().safeParse(value).success) {
return value;
}
if (NIP05.regex().test(value)) {
return value;
}
}

View file

@ -4,7 +4,7 @@ import { eventFixture } from '@/test.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
Deno.test('parseNoteContent', () => { Deno.test('parseNoteContent', () => {
const { html, links, firstUrl } = parseNoteContent('Hello, world!'); const { html, links, firstUrl } = parseNoteContent('Hello, world!', []);
assertEquals(html, 'Hello, world!'); assertEquals(html, 'Hello, world!');
assertEquals(links, []); assertEquals(links, []);
assertEquals(firstUrl, undefined); assertEquals(firstUrl, undefined);

View file

@ -4,40 +4,12 @@ import linkify from 'linkifyjs';
import { nip19, nip21, nip27 } from 'nostr-tools'; import { nip19, nip21, nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts';
import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts';
linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('nostr', true);
linkify.registerCustomProtocol('wss'); linkify.registerCustomProtocol('wss');
const linkifyOpts: linkify.Opts = {
render: {
hashtag: ({ content }) => {
const tag = content.replace(/^#/, '');
const href = Conf.local(`/tags/${tag}`);
return `<a class=\"mention hashtag\" href=\"${href}\" rel=\"tag\"><span>#</span>${tag}</a>`;
},
url: ({ attributes, content }) => {
try {
const { decoded } = nip21.parse(content);
const pubkey = getDecodedPubkey(decoded);
if (pubkey) {
const name = pubkey.substring(0, 8);
const href = Conf.local(`/users/${pubkey}`);
return `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`;
} else {
return '';
}
} catch {
const attr = Object.entries(attributes)
.map(([name, value]) => `${name}="${value}"`)
.join(' ');
return `<a ${attr}>${content}</a>`;
}
},
},
};
type Link = ReturnType<typeof linkify.find>[0]; type Link = ReturnType<typeof linkify.find>[0];
interface ParsedNoteContent { interface ParsedNoteContent {
@ -48,12 +20,42 @@ interface ParsedNoteContent {
} }
/** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ /** Convert Nostr content to Mastodon API HTML. Also return parsed data. */
function parseNoteContent(content: string): ParsedNoteContent { function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedNoteContent {
// Parsing twice is ineffecient, but I don't know how to do only once.
const html = linkifyStr(content, linkifyOpts).replace(/\n+$/, '');
const links = linkify.find(content).filter(isLinkURL); const links = linkify.find(content).filter(isLinkURL);
const firstUrl = links.find(isNonMediaLink)?.href; const firstUrl = links.find(isNonMediaLink)?.href;
const html = linkifyStr(content, {
render: {
hashtag: ({ content }) => {
const tag = content.replace(/^#/, '');
const href = Conf.local(`/tags/${tag}`);
return `<a class=\"mention hashtag\" href=\"${href}\" rel=\"tag\"><span>#</span>${tag}</a>`;
},
url: ({ attributes, content }) => {
try {
const { decoded } = nip21.parse(content);
const pubkey = getDecodedPubkey(decoded);
if (pubkey) {
const mention = mentions.find((m) => m.id === pubkey);
const npub = nip19.npubEncode(pubkey);
const acct = mention?.acct ?? npub;
const name = mention?.acct ?? npub.substring(0, 8);
const href = mention?.url ?? Conf.local(`/@${acct}`);
return `<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>`;
} else {
return '';
}
} catch {
const attr = Object.entries(attributes)
.map(([name, value]) => `${name}="${value}"`)
.join(' ');
return `<a ${attr}>${content}</a>`;
}
},
},
}).replace(/\n+$/, '');
return { return {
html, html,
links, links,

View file

@ -10,7 +10,7 @@ type ExtraMessage = string;
type splitPercentages = number; type splitPercentages = number;
export type DittoZapSplits = { export type DittoZapSplits = {
[key: Pubkey]: { amount: splitPercentages; message: ExtraMessage }; [key: Pubkey]: { weight: splitPercentages; message: ExtraMessage };
}; };
/** Gets zap splits from NIP-78 in DittoZapSplits format. */ /** Gets zap splits from NIP-78 in DittoZapSplits format. */
@ -30,7 +30,7 @@ export async function getZapSplits(store: NStore, pubkey: string): Promise<Ditto
tag[0] === 'p' && n.id().safeParse(tag[1]).success && tag[0] === 'p' && n.id().safeParse(tag[1]).success &&
percentageSchema.safeParse(tag[2]).success percentageSchema.safeParse(tag[2]).success
) { ) {
zapSplits[tag[1]] = { amount: Number(tag[2]), message: tag[3] }; zapSplits[tag[1]] = { weight: Number(tag[2]), message: tag[3] };
} }
} }

View file

@ -4,7 +4,7 @@ import { AppContext } from '@/app.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { listPaginationSchema, paginated, paginatedList, paginationSchema } from '@/utils/api.ts'; import { paginated, paginatedList } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
@ -43,7 +43,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
} }
async function renderAccounts(c: AppContext, pubkeys: string[]) { async function renderAccounts(c: AppContext, pubkeys: string[]) {
const { offset, limit } = listPaginationSchema.parse(c.req.query()); const { offset, limit } = c.get('listPagination');
const authors = pubkeys.reverse().slice(offset, offset + limit); const authors = pubkeys.reverse().slice(offset, offset + limit);
const store = await Storages.db(); const store = await Storages.db();
@ -73,7 +73,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
} }
const store = await Storages.db(); const store = await Storages.db();
const { limit } = paginationSchema.parse(c.req.query()); const { limit } = c.get('pagination');
const events = await store.query([{ kinds: [1], ids, limit }], { signal }) const events = await store.query([{ kinds: [1], ids, limit }], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, store, signal }));

View file

@ -42,10 +42,11 @@ async function renderAccount(
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
const acct = parsed05?.handle || npub;
return { return {
id: pubkey, id: pubkey,
acct: parsed05?.handle || npub, acct,
avatar: picture, avatar: picture,
avatar_static: picture, avatar_static: picture,
bot: false, bot: false,
@ -78,7 +79,8 @@ async function renderAccount(
} }
: undefined, : undefined,
statuses_count: event.author_stats?.notes_count ?? 0, statuses_count: event.author_stats?.notes_count ?? 0,
url: Conf.local(`/users/${pubkey}`), uri: Conf.local(`/users/${acct}`),
url: Conf.local(`/@${acct}`),
username: parsed05?.nickname || npub.substring(0, 8), username: parsed05?.nickname || npub.substring(0, 8),
ditto: { ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),

View file

@ -1,19 +1,16 @@
import { Storages } from '@/storages.ts'; import { NostrEvent } from '@nostrify/nostrify';
import { hasTag } from '@/utils/tags.ts'; import { hasTag } from '@/utils/tags.ts';
async function renderRelationship(sourcePubkey: string, targetPubkey: string) { interface RenderRelationshipOpts {
const db = await Storages.db(); sourcePubkey: string;
targetPubkey: string;
const events = await db.query([ event3: NostrEvent | undefined;
{ kinds: [3], authors: [sourcePubkey], limit: 1 }, target3: NostrEvent | undefined;
{ kinds: [3], authors: [targetPubkey], limit: 1 }, event10000: NostrEvent | undefined;
{ kinds: [10000], authors: [sourcePubkey], limit: 1 }, }
]);
const event3 = events.find((event) => event.kind === 3 && event.pubkey === sourcePubkey);
const target3 = events.find((event) => event.kind === 3 && event.pubkey === targetPubkey);
const event10000 = events.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey);
function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) {
return { return {
id: targetPubkey, id: targetPubkey,
following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false, following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false,

View file

@ -46,13 +46,14 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
[{ kinds: [0], authors: mentionedPubkeys, limit: mentionedPubkeys.length }], [{ kinds: [0], authors: mentionedPubkeys, limit: mentionedPubkeys.length }],
); );
const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags)); const mentions = await Promise.all(
mentionedPubkeys.map((pubkey) => renderMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))),
);
const [mentions, card, relatedEvents] = await Promise const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions);
const [card, relatedEvents] = await Promise
.all([ .all([
Promise.all(
mentionedPubkeys.map((pubkey) => toMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))),
),
firstUrl ? unfurlCardCached(firstUrl) : null, firstUrl ? unfurlCardCached(firstUrl) : null,
viewerPubkey viewerPubkey
? await store.query([ ? await store.query([
@ -71,7 +72,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
const zapEvent = relatedEvents.find((event) => event.kind === 9734); const zapEvent = relatedEvents.find((event) => event.kind === 9734);
const content = buildInlineRecipients(mentions) + html; const compatMentions = buildInlineRecipients(mentions.filter((m) => {
if (m.id === account.id) return false;
if (html.includes(m.url)) return false;
return true;
}));
const cw = event.tags.find(([name]) => name === 'content-warning'); const cw = event.tags.find(([name]) => name === 'content-warning');
const subject = event.tags.find(([name]) => name === 'subject'); const subject = event.tags.find(([name]) => name === 'subject');
@ -95,7 +100,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
id: event.id, id: event.id,
account, account,
card, card,
content, content: compatMentions + html,
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyId ?? null, in_reply_to_id: replyId ?? null,
in_reply_to_account_id: null, in_reply_to_account_id: null,
@ -121,8 +126,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
poll: null, poll: null,
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null, quote_id: event.quote?.id ?? null,
uri: Conf.local(`/${note}`), uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: Conf.local(`/${note}`), url: Conf.local(`/@${account.acct}/${event.id}`),
zapped: Boolean(zapEvent), zapped: Boolean(zapEvent),
ditto: { ditto: {
external_url: Conf.external(note), external_url: Conf.external(note),
@ -152,25 +157,14 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise<
}; };
} }
async function toMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> { async function renderMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> {
const account = event ? await renderAccount(event) : undefined; const account = event ? await renderAccount(event) : await accountFromPubkey(pubkey);
return {
if (account) { id: account.id,
return { acct: account.acct,
id: account.id, username: account.username,
acct: account.acct, url: account.url,
username: account.username, };
url: account.url,
};
} else {
const npub = nip19.npubEncode(pubkey);
return {
id: pubkey,
acct: npub,
username: npub.substring(0, 8),
url: Conf.local(`/users/${pubkey}`),
};
}
} }
function buildInlineRecipients(mentions: MastodonMention[]): string { function buildInlineRecipients(mentions: MastodonMention[]): string {
@ -178,7 +172,7 @@ function buildInlineRecipients(mentions: MastodonMention[]): string {
const elements = mentions.reduce<string[]>((acc, { url, username }) => { const elements = mentions.reduce<string[]>((acc, { url, username }) => {
const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username;
acc.push(`<a href="${url}" class="u-url mention" rel="ugc">@<span>${name}</span></a>`); acc.push(`<span class="h-card"><a class="u-url mention" href="${url}" rel="ugc">@<span>${name}</span></a></span>`);
return acc; return acc;
}, []); }, []);