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",
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
"@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",
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
"@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-tools": "npm:nostr-tools@2.5.1",
"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",
"prom-client": "npm:prom-client@^15.1.2",
"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:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.3",
"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.4": "jsr:@nostrify/nostrify@0.22.4",
"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/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.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.7.0": "npm:nostr-tools@2.7.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:prom-client@^15.1.2": "npm:prom-client@15.1.2",
"npm:tldts@^6.0.14": "npm:tldts@6.1.18",
@ -137,6 +138,15 @@
"@lambdalisue/async@2.1.1": {
"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": {
"integrity": "1c8a7847e5773213044b491e85fd7cafae2ad194ce59da4d957d2b27c776b42d",
"dependencies": [
@ -166,21 +176,24 @@
"npm:zod@^3.23.8"
]
},
"@nostrify/nostrify@0.29.0": {
"integrity": "d0489b62441c891324cce60c14bb398013259494b5ad9d21ec6dfbf0ca7368c9",
"@nostrify/nostrify@0.30.0": {
"integrity": "7c29e7d8b5a0a81e238170ac1e7ad708bc72dd8f478d8d82c30598fb4eff9b9c",
"dependencies": [
"jsr:@nostrify/types@^0.30.0",
"jsr:@std/crypto@^0.224.0",
"jsr:@std/encoding@^0.224.1",
"npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.4.0",
"npm:@scure/bip39@^1.3.0",
"npm:kysely@^0.27.3",
"npm:lru-cache@^10.2.0",
"npm:nostr-tools@^2.7.0",
"npm:websocket-ts@^2.1.5",
"npm:zod@^3.23.8"
]
},
"@nostrify/types@0.30.0": {
"integrity": "1f38fa849cff930bd709edbf94ef9ac02f46afb8b851f86c8736517b354616da"
},
"@soapbox/kysely-deno-sqlite@2.2.0": {
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
"dependencies": [
@ -935,10 +948,6 @@
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dependencies": {}
},
"path-to-regexp@6.2.1": {
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==",
"dependencies": {}
},
"path-to-regexp@7.1.0": {
"integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==",
"dependencies": {}
@ -1818,7 +1827,8 @@
"jsr:@db/sqlite@^0.11.1",
"jsr:@hono/hono@^4.4.6",
"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/stickynotes@^0.4.0",
"jsr:@std/assert@^0.225.1",
@ -1851,7 +1861,7 @@
"npm:nostr-relaypool2@0.6.34",
"npm:nostr-tools@2.5.1",
"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:tldts@^6.0.14",
"npm:tseep@^1.2.1",

View file

@ -40,8 +40,10 @@ import {
adminRelaysController,
adminSetRelaysController,
deleteZapSplitsController,
getZapSplitsController,
nameRequestController,
nameRequestsController,
statusZapSplitsController,
updateZapSplitsController,
} from '@/controllers/api/ditto.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 { cspMiddleware } from '@/middleware/cspMiddleware.ts';
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
import { requireSigner } from '@/middleware/requireSigner.ts';
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
@ -131,8 +134,12 @@ interface AppEnv extends HonoEnv {
uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent;
/** Store */
/** Storage for the user, might filter out unwanted content. */
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('/api/*', metricsMiddleware, logger(debug));
app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug));
app.use('/.well-known/*', metricsMiddleware, logger(debug));
app.use('/users/*', 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.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.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 { uploadFile } from '@/utils/upload.ts';
import { nostrNow } from '@/utils.ts';
import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { lookupAccount } from '@/utils/lookup.ts';
import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts';
import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts';
import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts';
@ -110,45 +110,38 @@ const accountSearchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent),
resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
});
const accountSearchController: AppController = async (c) => {
const result = accountSearchQuerySchema.safeParse(c.req.query());
const { signal } = c.req.raw;
const { limit } = c.get('pagination');
const result = accountSearchQuerySchema.safeParse(c.req.query());
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422);
}
const { q, limit } = result.data;
const query = decodeURIComponent(q);
const query = decodeURIComponent(result.data.q);
const store = await Storages.search();
const [event, events] = await Promise.all([
lookupAccount(query),
store.query([{ kinds: [0], search: query, limit }], { signal }),
]);
const lookup = extractIdentifier(query);
const event = await lookupAccount(lookup ?? query);
const results = await hydrateEvents({
events: event ? [event, ...events] : events,
store,
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([]);
}
if (!event && lookup) {
const pubkey = await lookupPubkey(lookup);
return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []);
}
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);
};
@ -160,7 +153,25 @@ const relationshipsController: AppController = async (c) => {
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);
};
@ -174,7 +185,7 @@ const accountStatusesQuerySchema = z.object({
const accountStatusesController: AppController = async (c) => {
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 { signal } = c.req.raw;
@ -325,7 +336,7 @@ const followController: AppController = async (c) => {
c,
);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
const relationship = await getRelationship(sourcePubkey, targetPubkey);
relationship.following = true;
return c.json(relationship);
@ -342,13 +353,13 @@ const unfollowController: AppController = async (c) => {
c,
);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
const relationship = await getRelationship(sourcePubkey, targetPubkey);
return c.json(relationship);
};
const followersController: AppController = (c) => {
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 }]);
};
@ -379,7 +390,7 @@ const muteController: AppController = async (c) => {
c,
);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
const relationship = await getRelationship(sourcePubkey, targetPubkey);
return c.json(relationship);
};
@ -394,13 +405,13 @@ const unmuteController: AppController = async (c) => {
c,
);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
const relationship = await getRelationship(sourcePubkey, targetPubkey);
return c.json(relationship);
};
const favouritesController: AppController = async (c) => {
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 store = await Storages.db();
@ -447,6 +458,23 @@ const familiarFollowersController: AppController = async (c) => {
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 {
accountController,
accountLookupController,

View file

@ -6,7 +6,7 @@ import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.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 { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
@ -29,7 +29,7 @@ const adminAccountQuerySchema = z.object({
const adminAccountsController: AppController = async (c) => {
const store = await Storages.db();
const params = paginationSchema.parse(c.req.query());
const params = c.get('pagination');
const { signal } = c.req.raw;
const {
local,

View file

@ -1,4 +1,7 @@
import { z } from 'zod';
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.
@ -14,10 +17,14 @@ const FAKE_APP = {
vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
};
const createAppSchema = z.object({
redirect_uris: z.string().url().optional(),
});
const createAppController: AppController = async (c) => {
// TODO: Handle both formData and json. 422 on parsing error.
try {
const { redirect_uris } = await c.req.json();
const { redirect_uris } = createAppSchema.parse(await parseBody(c.req.raw));
return c.json({
...FAKE_APP,

View file

@ -1,18 +1,22 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { accountFromPubkey } from '@/views/mastodon/accounts.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 { 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 { 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']);
@ -111,7 +115,7 @@ export const nameRequestsController: AppController = async (c) => {
const signer = c.get('signer')!;
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 filter: NostrFilter = {
@ -156,7 +160,7 @@ export const nameRequestsController: AppController = async (c) => {
const zapSplitSchema = z.record(
n.id(),
z.object({
amount: z.number().int().min(1).max(100),
weight: z.number().int().min(1).max(100),
message: z.string().max(500),
}),
);
@ -170,8 +174,8 @@ export const updateZapSplitsController: AppController = async (c) => {
return c.json({ error: result.error }, 400);
}
const zap_split = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
if (!dittoZapSplit) {
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 },
(tags) =>
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),
c,
);
@ -205,8 +209,8 @@ export const deleteZapSplitsController: AppController = async (c) => {
return c.json({ error: result.error }, 400);
}
const zap_split = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
if (!dittoZapSplit) {
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);
};
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 { Storages } from '@/storages.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 instanceV1Controller: AppController = async (c) => {
const { host, protocol } = Conf.url;
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`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -72,9 +68,6 @@ const instanceV1Controller: AppController = async (c) => {
},
},
rules: [],
ditto: {
zap_split,
},
});
};

View file

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

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import { type AppController } from '@/app.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 { renderAdminReport } 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 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 filter: NostrFilter = {

View file

@ -5,14 +5,11 @@ import { z } from 'zod';
import { AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.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 { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.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({
q: z.string().transform(decodeURIComponent),
@ -33,43 +30,44 @@ const searchController: AppController = async (c) => {
return c.json({ error: 'Bad request', schema: result.error }, 422);
}
const [event, events] = await Promise.all([
lookupEvent(result.data, signal),
searchEvents(result.data, signal),
]);
const event = await lookupEvent(result.data, signal);
const lookup = extractIdentifier(result.data.q);
if (event) {
events.push(event);
// Render account from pubkey.
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 [accounts, statuses] = await Promise.all([
Promise.all(
results
events
.filter((event) => event.kind === 0)
.map((event) => renderAccount(event))
.filter(Boolean),
),
Promise.all(
results
events
.filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey }))
.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({
accounts,
statuses,
@ -121,54 +119,55 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Nos
/** Get filters to lookup the input value. */
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> {
const filters: NostrFilter[] = [];
const accounts = !type || type === 'accounts';
const statuses = !type || type === 'statuses';
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;
}
if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) {
try {
const result = nip19.decode(q);
switch (result.type) {
case 'npub':
if (accounts) filters.push({ kinds: [0], authors: [result.data] });
break;
case 'nprofile':
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
break;
case 'note':
if (statuses) {
filters.push({ kinds: [1], ids: [result.data] });
}
break;
case 'nevent':
if (statuses) {
filters.push({ kinds: [1], ids: [result.data.id] });
}
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
const lookup = extractIdentifier(q);
if (!lookup) return [];
try {
const result = nip19.decode(lookup);
const filters: NostrFilter[] = [];
switch (result.type) {
case 'npub':
if (accounts) filters.push({ kinds: [0], authors: [result.data] });
break;
case 'nprofile':
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
break;
case 'note':
if (statuses) filters.push({ kinds: [1], ids: [result.data] });
break;
case 'nevent':
if (statuses) filters.push({ kinds: [1], ids: [result.data.id] });
break;
}
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 };

View file

@ -19,15 +19,7 @@ import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts';
import {
createEvent,
listPaginationSchema,
paginated,
paginatedList,
paginationSchema,
parseBody,
updateListEvent,
} from '@/utils/api.ts';
import { createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts';
import { getInvoice, getLnurl } from '@/utils/lnurl.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 lnurl = getLnurl(meta);
const zap_split = await getZapSplits(store, Conf.pubkey);
if (lnurl && zap_split) {
const dittoZapSplit = await getZapSplits(store, Conf.pubkey);
if (lnurl && dittoZapSplit) {
let totalSplit = 0;
for (const pubkey in zap_split) {
totalSplit += zap_split[pubkey].amount;
tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.toString()]);
for (const pubkey in dittoZapSplit) {
totalSplit += dittoZapSplit[pubkey].weight;
tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString()]);
}
if (totalSplit) {
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 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 }], {
filterFn: ({ content }) => content === '+',
@ -364,13 +356,13 @@ const unreblogStatusController: AppController = async (c) => {
const rebloggedByController: AppController = (c) => {
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 }]);
};
const quotesController: AppController = async (c) => {
const id = c.req.param('id');
const params = paginationSchema.parse(c.req.query());
const params = c.get('pagination');
const store = await Storages.db();
const [event] = await store.query([{ ids: [id], kinds: [1] }]);
@ -571,7 +563,7 @@ const zapController: AppController = async (c) => {
const zappedByController: AppController = async (c) => {
const id = c.req.param('id');
const params = listPaginationSchema.parse(c.req.query());
const params = c.get('listPagination');
const store = await Storages.db();
const db = await DittoDB.getInstance();

View file

@ -4,13 +4,13 @@ import { matchFilter } from 'nostr-tools';
import { AppContext, AppController } from '@/app.ts';
import { Conf } from '@/config.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 { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => {
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 accounts = suggestions.map(({ account }) => account);
return paginatedList(c, params, accounts);
@ -18,12 +18,12 @@ export const suggestionsV1Controller: AppController = async (c) => {
export const suggestionsV2Controller: AppController = async (c) => {
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);
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 store = c.get('store');

View file

@ -6,12 +6,12 @@ import { Conf } from '@/config.ts';
import { getFeedPubkeys } from '@/queries.ts';
import { booleanParamSchema } from '@/schema.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 { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
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 authors = await getFeedPubkeys(pubkey);
return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]);
@ -23,7 +23,7 @@ const publicQuerySchema = z.object({
});
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 filter: NostrFilter = { kinds: [1], ...params };
@ -39,13 +39,13 @@ const publicTimelineController: AppController = (c) => {
const hashtagTimelineController: AppController = (c) => {
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 }]);
};
const suggestedTimelineController: AppController = async (c) => {
const store = c.get('store');
const params = paginationSchema.parse(c.req.query());
const params = c.get('pagination');
const [follows] = await store.query(
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }],

View file

@ -36,6 +36,7 @@ export interface MastodonAccount {
};
};
statuses_count: number;
uri: string;
url: string;
username: string;
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
import { NDatabase } from '@nostrify/db';
import {
NDatabase,
NIP50,
NKinds,
NostrEvent,

View file

@ -2,9 +2,10 @@ import fs from 'node:fs/promises';
import path from 'node:path';
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 { finalizeEvent, generateSecretKey } from 'nostr-tools';
import { NDatabase, NostrEvent } from '@nostrify/nostrify';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js';
import postgres from 'postgres';

View file

@ -19,7 +19,7 @@ function bech32ToPubkey(bech32: string): string | undefined {
case 'npub':
return decoded.data;
}
} catch (_) {
} catch {
//
}
}
@ -78,11 +78,6 @@ async function sha256(message: string): Promise<string> {
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. */
function isNostrId(value: unknown): boolean {
return n.id().safeParse(value).success;
@ -93,18 +88,6 @@ function isURL(value: unknown): boolean {
return z.string().url().safeParse(value).success;
}
export {
bech32ToPubkey,
dedupeEvents,
eventAge,
findTag,
isNostrId,
isURL,
type Nip05,
nostrDate,
nostrNow,
parseNip05,
sha256,
};
export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 };
export { Time } from '@/utils/time.ts';

View file

@ -5,7 +5,6 @@ import Debug from '@soapbox/stickynotes/debug';
import { parseFormData } from 'formdata-helper';
import { EventTemplate } from 'nostr-tools';
import * as TypeFest from 'type-fest';
import { z } from 'zod';
import { type AppContext } from '@/app.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. */
function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined {
if (events.length <= 1) return;
@ -219,12 +208,6 @@ function paginated(c: AppContext, events: NostrEvent[], entities: (Entity | unde
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. */
function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined {
const { origin } = Conf.url;
@ -242,15 +225,10 @@ function buildListLinkHeader(url: string, params: { offset: number; limit: numbe
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
}
interface PaginatedListParams {
offset: number;
limit: number;
}
/** paginate a list of tags. */
function paginatedList(
c: AppContext,
params: PaginatedListParams,
params: { offset: number; limit: number },
entities: unknown[],
headers: HeaderRecord = {},
) {
@ -296,13 +274,9 @@ export {
createAdminEvent,
createEvent,
type EventStub,
listPaginationSchema,
localRequest,
paginated,
paginatedList,
type PaginatedListParams,
type PaginationParams,
paginationSchema,
parseBody,
updateAdminEvent,
updateEvent,

View file

@ -1,4 +1,6 @@
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 { 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';
Deno.test('parseNoteContent', () => {
const { html, links, firstUrl } = parseNoteContent('Hello, world!');
const { html, links, firstUrl } = parseNoteContent('Hello, world!', []);
assertEquals(html, 'Hello, world!');
assertEquals(links, []);
assertEquals(firstUrl, undefined);

View file

@ -4,40 +4,12 @@ import linkify from 'linkifyjs';
import { nip19, nip21, nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts';
import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts';
linkify.registerCustomProtocol('nostr', true);
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];
interface ParsedNoteContent {
@ -48,12 +20,42 @@ interface ParsedNoteContent {
}
/** Convert Nostr content to Mastodon API HTML. Also return parsed data. */
function parseNoteContent(content: string): ParsedNoteContent {
// Parsing twice is ineffecient, but I don't know how to do only once.
const html = linkifyStr(content, linkifyOpts).replace(/\n+$/, '');
function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedNoteContent {
const links = linkify.find(content).filter(isLinkURL);
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 {
html,
links,

View file

@ -10,7 +10,7 @@ type ExtraMessage = string;
type splitPercentages = number;
export type DittoZapSplits = {
[key: Pubkey]: { amount: splitPercentages; message: ExtraMessage };
[key: Pubkey]: { weight: splitPercentages; message: ExtraMessage };
};
/** 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 &&
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 { renderAccount } from '@/views/mastodon/accounts.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 { 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[]) {
const { offset, limit } = listPaginationSchema.parse(c.req.query());
const { offset, limit } = c.get('listPagination');
const authors = pubkeys.reverse().slice(offset, offset + limit);
const store = await Storages.db();
@ -73,7 +73,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
}
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 })
.then((events) => hydrateEvents({ events, store, signal }));

View file

@ -42,10 +42,11 @@ async function renderAccount(
const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
const acct = parsed05?.handle || npub;
return {
id: pubkey,
acct: parsed05?.handle || npub,
acct,
avatar: picture,
avatar_static: picture,
bot: false,
@ -78,7 +79,8 @@ async function renderAccount(
}
: undefined,
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),
ditto: {
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';
async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
const db = await Storages.db();
const events = await db.query([
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
{ kinds: [3], authors: [targetPubkey], limit: 1 },
{ 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);
interface RenderRelationshipOpts {
sourcePubkey: string;
targetPubkey: string;
event3: NostrEvent | undefined;
target3: NostrEvent | undefined;
event10000: NostrEvent | undefined;
}
function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) {
return {
id: targetPubkey,
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 }],
);
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([
Promise.all(
mentionedPubkeys.map((pubkey) => toMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))),
),
firstUrl ? unfurlCardCached(firstUrl) : null,
viewerPubkey
? await store.query([
@ -71,7 +72,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
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 subject = event.tags.find(([name]) => name === 'subject');
@ -95,7 +100,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
id: event.id,
account,
card,
content,
content: compatMentions + html,
created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyId ?? null,
in_reply_to_account_id: null,
@ -121,8 +126,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
poll: null,
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null,
uri: Conf.local(`/${note}`),
url: Conf.local(`/${note}`),
uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: Conf.local(`/@${account.acct}/${event.id}`),
zapped: Boolean(zapEvent),
ditto: {
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> {
const account = event ? await renderAccount(event) : undefined;
if (account) {
return {
id: account.id,
acct: account.acct,
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}`),
};
}
async function renderMention(pubkey: string, event?: NostrEvent): Promise<MastodonMention> {
const account = event ? await renderAccount(event) : await accountFromPubkey(pubkey);
return {
id: account.id,
acct: account.acct,
username: account.username,
url: account.url,
};
}
function buildInlineRecipients(mentions: MastodonMention[]): string {
@ -178,7 +172,7 @@ function buildInlineRecipients(mentions: MastodonMention[]): string {
const elements = mentions.reduce<string[]>((acc, { url, 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;
}, []);