Merge remote-tracking branch 'origin/main' into feat-persist-user-preference

This commit is contained in:
Alex Gleason
2024-05-20 14:02:38 -05:00
176 changed files with 3607 additions and 2299 deletions

View File

@@ -1,15 +1,14 @@
import { NostrFilter } from '@nostrify/nostrify';
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { nip19 } from '@/deps.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { eventsDB, searchStore } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts';
import { uploadFile } from '@/upload.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';
@@ -18,7 +17,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { bech32ToPubkey } from '@/utils.ts';
const usernameSchema = z
.string().min(1).max(30)
@@ -30,7 +29,7 @@ const createAccountSchema = z.object({
});
const createAccountController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const result = createAccountSchema.safeParse(await c.req.json());
if (!result.success) {
@@ -46,28 +45,32 @@ const createAccountController: AppController = async (c) => {
};
const verifyCredentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const event = await getAuthor(pubkey, { relations: ['author_stats'] });
if (event) {
const account = await renderAccount(event, { withSource: true });
const eventsDB = await Storages.db();
const [userPreferencesEvent] = await eventsDB.query([{
const [author, [settingsStore]] = await Promise.all([
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
eventsDB.query([{
authors: [pubkey],
kinds: [30078],
'#d': ['pub.ditto.pleroma_settings_store'],
limit: 1,
}]);
if (userPreferencesEvent) {
const signer = new APISigner(c);
const userPreference = JSON.parse(await signer.nip44.decrypt(pubkey, userPreferencesEvent.content));
(account.pleroma as any).settings_store = userPreference;
}
}]),
]);
return c.json(account);
} else {
return c.json(await accountFromPubkey(pubkey, { withSource: true }));
const account = author
? await renderAccount(author, { withSource: true })
: await accountFromPubkey(pubkey, { withSource: true });
if (settingsStore) {
const data = await signer.nip44!.decrypt(pubkey, settingsStore.content);
account.pleroma.settings_store = JSON.parse(data);
}
return c.json(account);
};
const accountController: AppController = async (c) => {
@@ -92,28 +95,44 @@ const accountLookupController: AppController = async (c) => {
if (event) {
return c.json(await renderAccount(event));
}
return c.json({ error: 'Could not find user.' }, 404);
try {
const pubkey = bech32ToPubkey(decodeURIComponent(acct)) as string;
return c.json(await accountFromPubkey(pubkey));
} catch (e) {
console.log(e);
return c.json({ error: 'Could not find user.' }, 404);
}
};
const accountSearchController: AppController = async (c) => {
const q = c.req.query('q');
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)),
});
if (!q) {
return c.json({ error: 'Missing `q` query parameter.' }, 422);
const accountSearchController: AppController = async (c) => {
const result = accountSearchQuerySchema.safeParse(c.req.query());
const { signal } = c.req.raw;
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 422);
}
const { q, limit } = result.data;
const query = decodeURIComponent(q);
const store = await Storages.search();
const [event, events] = await Promise.all([
lookupAccount(query),
searchStore.query([{ kinds: [0], search: query, limit: 20 }], { signal: c.req.raw.signal }),
store.query([{ kinds: [0], search: query, limit }], { signal }),
]);
const results = await hydrateEvents({
events: event ? [event, ...events] : events,
storage: eventsDB,
signal: c.req.raw.signal,
store,
signal,
});
if ((results.length < 1) && query.match(/npub1\w+/)) {
@@ -132,7 +151,7 @@ const accountSearchController: AppController = async (c) => {
};
const relationshipsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
if (!ids.success) {
@@ -157,8 +176,10 @@ const accountStatusesController: AppController = async (c) => {
const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw;
const store = await Storages.db();
if (pinned) {
const [pinEvent] = await eventsDB.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal });
const [pinEvent] = await store.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal });
if (pinEvent) {
const pinnedEventIds = getTagSet(pinEvent.tags, 'e');
return renderStatuses(c, [...pinnedEventIds].reverse());
@@ -179,8 +200,8 @@ const accountStatusesController: AppController = async (c) => {
filter['#t'] = [tagged];
}
const events = await eventsDB.query([filter], { signal })
.then((events) => hydrateEvents({ events, storage: eventsDB, signal }))
const events = await store.query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal }))
.then((events) => {
if (exclude_replies) {
return events.filter((event) => !findReplyTag(event.tags));
@@ -188,7 +209,11 @@ const accountStatusesController: AppController = async (c) => {
return events;
});
const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })));
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })),
);
return paginated(c, events, statuses);
};
@@ -201,11 +226,12 @@ const updateCredentialsSchema = z.object({
bot: z.boolean().optional(),
discoverable: z.boolean().optional(),
nip05: z.string().optional(),
pleroma_settings_store: z.object({ soapbox_fe: z.record(z.string(), z.unknown()) }).optional(),
pleroma_settings_store: z.unknown().optional(),
});
const updateCredentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const body = await parseBody(c.req.raw);
const result = updateCredentialsSchema.safeParse(body);
@@ -214,7 +240,7 @@ const updateCredentialsController: AppController = async (c) => {
}
const author = await getAuthor(pubkey);
const meta = author ? jsonMetaContentSchema.parse(author.content) : {};
const meta = author ? n.json().pipe(n.metadata()).catch({}).parse(author.content) : {};
const {
avatar: avatarFile,
@@ -225,8 +251,8 @@ const updateCredentialsController: AppController = async (c) => {
} = result.data;
const [avatar, header] = await Promise.all([
avatarFile ? uploadFile(avatarFile, { pubkey }) : undefined,
headerFile ? uploadFile(headerFile, { pubkey }) : undefined,
avatarFile ? uploadFile(c, avatarFile, { pubkey }) : undefined,
headerFile ? uploadFile(c, headerFile, { pubkey }) : undefined,
]);
meta.name = display_name ?? meta.name;
@@ -241,55 +267,46 @@ const updateCredentialsController: AppController = async (c) => {
tags: [],
}, c);
const pleroma_frontend = result.data.pleroma_settings_store;
if (pleroma_frontend) {
const signer = new APISigner(c);
const account = await renderAccount(event, { withSource: true });
const settingsStore = result.data.pleroma_settings_store;
if (settingsStore) {
await createEvent({
kind: 30078,
tags: [['d', 'pub.ditto.pleroma_settings_store']],
content: await signer.nip44.encrypt(pubkey, JSON.stringify(pleroma_frontend)),
content: await signer.nip44!.encrypt(pubkey, JSON.stringify(settingsStore)),
}, c);
}
const account = await renderAccount(event, { withSource: true });
const [userPreferencesEvent] = await eventsDB.query([{
authors: [pubkey],
kinds: [30078],
'#d': ['pub.ditto.pleroma_settings_store'],
limit: 1,
}]);
if (userPreferencesEvent) {
const signer = new APISigner(c);
const userPreference = JSON.parse(await signer.nip44.decrypt(pubkey, userPreferencesEvent.content));
(account.pleroma as any).settings_store = userPreference;
}
account.pleroma.settings_store = settingsStore;
return c.json(account);
};
/** https://docs.joinmastodon.org/methods/accounts/#follow */
const followController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [3], authors: [sourcePubkey] },
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]),
c,
);
const relationship = await renderRelationship(sourcePubkey, targetPubkey);
relationship.following = true;
return c.json(relationship);
};
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */
const unfollowController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [3], authors: [sourcePubkey] },
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]),
c,
);
@@ -311,12 +328,22 @@ const followingController: AppController = async (c) => {
};
/** https://docs.joinmastodon.org/methods/accounts/#block */
const blockController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const blockController: AppController = (c) => {
return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
};
/** https://docs.joinmastodon.org/methods/accounts/#unblock */
const unblockController: AppController = (c) => {
return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
};
/** https://docs.joinmastodon.org/methods/accounts/#mute */
const muteController: AppController = async (c) => {
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [10000], authors: [sourcePubkey] },
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]),
c,
);
@@ -325,13 +352,13 @@ const blockController: AppController = async (c) => {
return c.json(relationship);
};
/** https://docs.joinmastodon.org/methods/accounts/#unblock */
const unblockController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
/** https://docs.joinmastodon.org/methods/accounts/#unmute */
const unmuteController: AppController = async (c) => {
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
{ kinds: [10000], authors: [sourcePubkey] },
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]),
c,
);
@@ -341,11 +368,13 @@ const unblockController: AppController = async (c) => {
};
const favouritesController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const params = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw;
const events7 = await eventsDB.query(
const store = await Storages.db();
const events7 = await store.query(
[{ kinds: [7], authors: [pubkey], ...params }],
{ signal },
);
@@ -354,10 +383,14 @@ const favouritesController: AppController = async (c) => {
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
.filter((id): id is string => !!id);
const events1 = await eventsDB.query([{ kinds: [1], ids }], { signal })
.then((events) => hydrateEvents({ events, storage: eventsDB, signal }));
const events1 = await store.query([{ kinds: [1], ids }], { signal })
.then((events) => hydrateEvents({ events, store, signal }));
const statuses = await Promise.all(events1.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })));
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
events1.map((event) => renderStatus(event, { viewerPubkey })),
);
return paginated(c, events1, statuses);
};
@@ -372,9 +405,11 @@ export {
followController,
followersController,
followingController,
muteController,
relationshipsController,
unblockController,
unfollowController,
unmuteController,
updateCredentialsController,
verifyCredentialsController,
};

View File

@@ -2,10 +2,12 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { booleanParamSchema } from '@/schema.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { addTag } from '@/tags.ts';
import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts';
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
import { paginated, paginationSchema } from '@/utils/api.ts';
const adminAccountQuerySchema = z.object({
local: booleanParamSchema.optional(),
@@ -38,16 +40,17 @@ const adminAccountsController: AppController = async (c) => {
return c.json([]);
}
const store = await Storages.db();
const { since, until, limit } = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw;
const events = await eventsDB.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { signal });
const events = await store.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { signal });
const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!);
const authors = await eventsDB.query([{ kinds: [0], authors: pubkeys }], { signal });
const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
for (const event of events) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
event.d_author = authors.find((author) => author.pubkey === d);
(event as DittoEvent).d_author = authors.find((author) => author.pubkey === d);
}
const accounts = await Promise.all(
@@ -57,4 +60,32 @@ const adminAccountsController: AppController = async (c) => {
return paginated(c, events, accounts);
};
export { adminAccountsController };
const adminAccountActionSchema = z.object({
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']),
});
const adminAccountAction: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = adminAccountActionSchema.safeParse(body);
const authorId = c.req.param('id');
if (!result.success) {
return c.json({ error: 'This action is not allowed' }, 403);
}
const { data } = result;
if (data.type !== 'disable') {
return c.json({ error: 'Record invalid' }, 422);
}
await updateListAdminEvent(
{ kinds: [10000], authors: [Conf.pubkey], limit: 1 },
(tags) => addTag(tags, ['p', authorId]),
c,
);
return c.json({}, 200);
};
export { adminAccountAction, adminAccountsController };

View File

@@ -1,24 +1,6 @@
import { type AppController } from '@/app.ts';
import { eventsDB } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderAccounts } from '@/views.ts';
import { AppController } from '@/app.ts';
/** https://docs.joinmastodon.org/methods/blocks/#get */
const blocksController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const { signal } = c.req.raw;
const [event10000] = await eventsDB.query(
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
{ signal },
);
if (event10000) {
const pubkeys = getTagSet(event10000.tags, 'p');
return renderAccounts(c, [...pubkeys].reverse());
} else {
return c.json([]);
}
export const blocksController: AppController = (c) => {
return c.json({ error: 'Blocking is not supported by Nostr' }, 422);
};
export { blocksController };

View File

@@ -1,14 +1,15 @@
import { type AppController } from '@/app.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderStatuses } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/bookmarks/#get */
const bookmarksController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const store = await Storages.db();
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10003] = await eventsDB.query(
const [event10003] = await store.query(
[{ kinds: [10003], authors: [pubkey], limit: 1 }],
{ signal },
);

View File

@@ -3,19 +3,22 @@ import { z } from 'zod';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
const markerSchema = z.enum(['read', 'write']);
const relaySchema = z.object({
url: z.string().url(),
read: z.boolean(),
write: z.boolean(),
marker: markerSchema.optional(),
});
type RelayEntity = z.infer<typeof relaySchema>;
export const adminRelaysController: AppController = async (c) => {
const [event] = await eventsDB.query([
const store = await Storages.db();
const [event] = await store.query([
{ kinds: [10002], authors: [Conf.pubkey], limit: 1 },
]);
@@ -27,16 +30,17 @@ export const adminRelaysController: AppController = async (c) => {
};
export const adminSetRelaysController: AppController = async (c) => {
const store = await Storages.db();
const relays = relaySchema.array().parse(await c.req.json());
const event = await new AdminSigner().signEvent({
kind: 10002,
tags: relays.map(({ url, read, write }) => ['r', url, read && write ? '' : read ? 'read' : 'write']),
tags: relays.map(({ url, marker }) => marker ? ['r', url, marker] : ['r', url]),
content: '',
created_at: Math.floor(Date.now() / 1000),
});
await eventsDB.event(event);
await store.event(event);
return c.json(renderRelays(event));
};
@@ -47,8 +51,7 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
if (name === 'r') {
const relay: RelayEntity = {
url,
read: !marker || marker === 'read',
write: !marker || marker === 'write',
marker: markerSchema.safeParse(marker).success ? marker as 'read' | 'write' : undefined,
};
acc.push(relay);
}

View File

@@ -1,23 +1,20 @@
import { type AppController } from '@/app.ts';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts';
const instanceController: AppController = async (c) => {
const { host, protocol } = Conf.url;
const { signal } = c.req.raw;
const [event] = await eventsDB.query([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], { signal });
const meta = jsonServerMetaSchema.parse(event?.content);
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
return c.json({
uri: host,
title: meta.name ?? 'Ditto',
description: meta.about ?? 'Nostr and the Fediverse',
short_description: meta.tagline ?? meta.about ?? 'Nostr and the Fediverse',
title: meta.name,
description: meta.about,
short_description: meta.tagline,
registrations: true,
max_toot_chars: Conf.postCharLimit,
configuration: {
@@ -43,6 +40,7 @@ const instanceController: AppController = async (c) => {
'mastodon_api_streaming',
'exposable_reactions',
'quote_posting',
'v2_suggestions',
],
},
},
@@ -56,7 +54,7 @@ const instanceController: AppController = async (c) => {
streaming_api: `${wsProtocol}//${host}`,
},
version: '0.0.0 (compatible; Ditto 0.0.1)',
email: meta.email ?? `postmaster@${host}`,
email: meta.email,
nostr: {
pubkey: Conf.pubkey,
relay: `${wsProtocol}//${host}/relay`,

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { parseBody } from '@/utils/api.ts';
const kv = await Deno.openKv();
type Timeline = 'home' | 'notifications';
interface Marker {
last_read_id: string;
version: number;
updated_at: string;
}
export const markersController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const timelines = c.req.queries('timeline[]') ?? [];
const results = await kv.getMany<Marker[]>(
timelines.map((timeline) => ['markers', pubkey, timeline]),
);
const marker = results.reduce<Record<string, Marker>>((acc, { key, value }) => {
if (value) {
const timeline = key[key.length - 1] as string;
acc[timeline] = value;
}
return acc;
}, {});
return c.json(marker);
};
const markerDataSchema = z.object({
last_read_id: z.string(),
});
export const updateMarkersController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
const timelines = Object.keys(record) as Timeline[];
const markers: Record<string, Marker> = {};
const entries = await kv.getMany<Marker[]>(
timelines.map((timeline) => ['markers', pubkey, timeline]),
);
for (const timeline of timelines) {
const last = entries.find(({ key }) => key[key.length - 1] === timeline);
const marker: Marker = {
last_read_id: record[timeline]!.last_read_id,
version: last?.value ? last.value.version + 1 : 1,
updated_at: new Date().toISOString(),
};
await kv.set(['markers', pubkey, timeline], marker);
markers[timeline] = marker;
}
return c.json(markers);
};

View File

@@ -4,7 +4,7 @@ import { AppController } from '@/app.ts';
import { fileSchema } from '@/schema.ts';
import { parseBody } from '@/utils/api.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { uploadFile } from '@/upload.ts';
import { uploadFile } from '@/utils/upload.ts';
const mediaBodySchema = z.object({
file: fileSchema,
@@ -14,7 +14,7 @@ const mediaBodySchema = z.object({
});
const mediaController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
const { signal } = c.req.raw;
@@ -24,7 +24,7 @@ const mediaController: AppController = async (c) => {
try {
const { file, description } = result.data;
const media = await uploadFile(file, { pubkey, description }, signal);
const media = await uploadFile(c, file, { pubkey, description }, signal);
return c.json(renderAttachment(media));
} catch (e) {
console.error(e);

View File

@@ -0,0 +1,25 @@
import { type AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { renderAccounts } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/mutes/#get */
const mutesController: AppController = async (c) => {
const store = await Storages.db();
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10000] = await store.query(
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
{ signal },
);
if (event10000) {
const pubkeys = getTagSet(event10000.tags, 'p');
return renderAccounts(c, [...pubkeys].reverse());
} else {
return c.json([]);
}
};
export { mutesController };

View File

@@ -1,20 +1,40 @@
import { type AppController } from '@/app.ts';
import { eventsDB } from '@/storages.ts';
import { NostrFilter } from '@nostrify/nostrify';
import { AppContext, AppController } from '@/app.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginationSchema } from '@/utils/api.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts';
const notificationsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { since, until } = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw;
const events = await eventsDB.query(
[{ kinds: [1], '#p': [pubkey], since, until }],
{ signal },
);
const statuses = await Promise.all(events.map((event) => renderNotification(event, pubkey)));
return paginated(c, events, statuses);
return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]);
};
async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const events = await store
.query(filters, { signal })
.then((events) => events.filter((event) => event.pubkey !== pubkey))
.then((events) => hydrateEvents({ events, store, signal }));
if (!events.length) {
return c.json([]);
}
const notifications = (await Promise
.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
.filter(Boolean);
if (!notifications.length) {
return c.json([]);
}
return paginated(c, events, notifications);
}
export { notificationsController };

View File

@@ -1,9 +1,12 @@
import { encodeBase64 } from '@std/encoding/base64';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { lodash, nip19 } from '@/deps.ts';
import { AppController } from '@/app.ts';
import { lodash } from '@/deps.ts';
import { nostrNow } from '@/utils.ts';
import { parseBody } from '@/utils/api.ts';
import { getClientConnectUri } from '@/utils/connect.ts';
const passwordGrantSchema = z.object({
grant_type: z.literal('password'),
@@ -59,25 +62,16 @@ const createTokenController: AppController = async (c) => {
};
/** Display the OAuth form. */
const oauthController: AppController = (c) => {
const oauthController: AppController = async (c) => {
const encodedUri = c.req.query('redirect_uri');
if (!encodedUri) {
return c.text('Missing `redirect_uri` query param.', 422);
}
const redirectUri = maybeDecodeUri(encodedUri);
const connectUri = await getClientConnectUri(c.req.raw.signal);
c.res.headers.set(
'content-security-policy',
"default-src 'self' 'sha256-m2qD6rbE2Ixbo2Bjy2dgQebcotRIAawW7zbmXItIYAM='",
);
return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Log in with Ditto</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script>
const script = `
window.addEventListener('load', function() {
if ('nostr' in window) {
nostr.getPublicKey().then(function(pubkey) {
@@ -86,7 +80,21 @@ const oauthController: AppController = (c) => {
});
}
});
</script>
`;
const hash = encodeBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(script)));
c.res.headers.set(
'content-security-policy',
`default-src 'self' 'sha256-${hash}'`,
);
return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Log in with Ditto</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script>${script}</script>
</head>
<body>
<form id="oauth_form" action="/oauth/authorize" method="post">
@@ -95,6 +103,8 @@ const oauthController: AppController = (c) => {
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${lodash.escape(redirectUri)}">
<button type="submit">Authorize</button>
</form>
<br>
<a href="${lodash.escape(connectUri)}">Nostr Connect</a>
</body>
</html>
`);

View File

@@ -1,15 +1,16 @@
import { NSchema as n, NStore } from '@nostrify/nostrify';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { createAdminEvent } from '@/utils/api.ts';
import { jsonSchema } from '@/schema.ts';
const frontendConfigController: AppController = async (c) => {
const configs = await getConfigs(c.req.raw.signal);
const store = await Storages.db();
const configs = await getConfigs(store, c.req.raw.signal);
const frontendConfig = configs.find(({ group, key }) => group === ':pleroma' && key === ':frontend_configurations');
if (frontendConfig) {
@@ -25,7 +26,8 @@ const frontendConfigController: AppController = async (c) => {
};
const configController: AppController = async (c) => {
const configs = await getConfigs(c.req.raw.signal);
const store = await Storages.db();
const configs = await getConfigs(store, c.req.raw.signal);
return c.json({ configs, need_reboot: false });
};
@@ -33,7 +35,8 @@ const configController: AppController = async (c) => {
const updateConfigController: AppController = async (c) => {
const { pubkey } = Conf;
const configs = await getConfigs(c.req.raw.signal);
const store = await Storages.db();
const configs = await getConfigs(store, c.req.raw.signal);
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
for (const { group, key, value } of newConfigs) {
@@ -63,10 +66,10 @@ const pleromaAdminDeleteStatusController: AppController = async (c) => {
return c.json({});
};
async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> {
async function getConfigs(store: NStore, signal: AbortSignal): Promise<PleromaConfig[]> {
const { pubkey } = Conf;
const [event] = await eventsDB.query([{
const [event] = await store.query([{
kinds: [30078],
authors: [pubkey],
'#d': ['pub.ditto.pleroma.config'],
@@ -75,7 +78,7 @@ async function getConfigs(signal: AbortSignal): Promise<PleromaConfig[]> {
try {
const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content);
return jsonSchema.pipe(configSchema.array()).catch([]).parse(decrypted);
return n.json().pipe(configSchema.array()).catch([]).parse(decrypted);
} catch (_e) {
return [];
}

View File

@@ -0,0 +1,121 @@
import { NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { createAdminEvent, createEvent, parseBody } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts';
import { renderReport } from '@/views/mastodon/reports.ts';
const reportSchema = z.object({
account_id: n.id(),
status_ids: n.id().array().default([]),
comment: z.string().max(1000).default(''),
category: z.string().default('other'),
// TODO: rules_ids[] is not implemented
});
/** https://docs.joinmastodon.org/methods/reports/#post */
const reportController: AppController = async (c) => {
const store = c.get('store');
const body = await parseBody(c.req.raw);
const result = reportSchema.safeParse(body);
if (!result.success) {
return c.json(result.error, 422);
}
const {
account_id,
status_ids,
comment,
category,
} = result.data;
const tags = [
['p', account_id, category],
['P', Conf.pubkey],
];
for (const status of status_ids) {
tags.push(['e', status, category]);
}
const event = await createEvent({
kind: 1984,
content: comment,
tags,
}, c);
await hydrateEvents({ events: [event], store });
return c.json(await renderReport(event));
};
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => {
const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey();
const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }))
.then((events) =>
Promise.all(
events.map((event) => renderAdminReport(event, { viewerPubkey })),
)
);
return c.json(reports);
};
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
const adminReportController: AppController = async (c) => {
const eventId = c.req.param('id');
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{
kinds: [1984],
ids: [eventId],
limit: 1,
}], { signal });
if (!event) {
return c.json({ error: 'This action is not allowed' }, 403);
}
await hydrateEvents({ events: [event], store, signal });
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey }));
};
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
const adminReportResolveController: AppController = async (c) => {
const eventId = c.req.param('id');
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{
kinds: [1984],
ids: [eventId],
limit: 1,
}], { signal });
if (!event) {
return c.json({ error: 'This action is not allowed' }, 403);
}
await hydrateEvents({ events: [event], store, signal });
await createAdminEvent({
kind: 5,
tags: [['e', event.id]],
content: 'Report closed.',
}, c);
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }));
};
export { adminReportController, adminReportResolveController, adminReportsController, reportController };

View File

@@ -1,11 +1,10 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { nip19 } from '@/deps.ts';
import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts';
import { searchStore } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
@@ -20,7 +19,7 @@ const searchQuerySchema = z.object({
type: z.enum(['accounts', 'statuses', 'hashtags']).optional(),
resolve: booleanParamSchema.optional().transform(Boolean),
following: z.boolean().default(false),
account_id: nostrIdSchema.optional(),
account_id: n.id().optional(),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
});
@@ -44,6 +43,7 @@ const searchController: AppController = async (c) => {
}
const results = dedupeEvents(events);
const viewerPubkey = await c.get('signer')?.getPublicKey();
const [accounts, statuses] = await Promise.all([
Promise.all(
@@ -55,7 +55,7 @@ const searchController: AppController = async (c) => {
Promise.all(
results
.filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))
.map((event) => renderStatus(event, { viewerPubkey }))
.filter(Boolean),
),
]);
@@ -78,7 +78,7 @@ const searchController: AppController = async (c) => {
};
/** Get events for the search params. */
function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise<NostrEvent[]> {
async function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise<NostrEvent[]> {
if (type === 'hashtags') return Promise.resolve([]);
const filter: NostrFilter = {
@@ -91,8 +91,10 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: Abort
filter.authors = [account_id];
}
return searchStore.query([filter], { signal })
.then((events) => hydrateEvents({ events, storage: searchStore, signal }));
const store = await Storages.search();
return store.query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal }));
}
/** Get event kinds to search from `type` query param. */
@@ -110,9 +112,10 @@ function typeToKinds(type: SearchQuery['type']): number[] {
/** Resolve a searched value into an event, if applicable. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
const filters = await getLookupFilters(query, signal);
const store = await Storages.search();
return searchStore.query(filters, { limit: 1, signal })
.then((events) => hydrateEvents({ events, storage: searchStore, signal }))
return store.query(filters, { limit: 1, signal })
.then((events) => hydrateEvents({ events, store, signal }))
.then(([event]) => event);
}

View File

@@ -1,21 +1,22 @@
import { NIP05, NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import ISO6391 from 'iso-639-1';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { ISO6391, nip19 } from '@/deps.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { addTag, deleteTag } from '@/tags.ts';
import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
const createStatusSchema = z.object({
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
@@ -31,6 +32,7 @@ const createStatusSchema = z.object({
sensitive: z.boolean().nullish(),
spoiler_text: z.string().nullish(),
status: z.string().nullish(),
to: z.string().array().nullish(),
visibility: z.enum(['public', 'unlisted', 'private', 'direct']).nullish(),
quote_id: z.string().nullish(),
}).refine(
@@ -47,7 +49,7 @@ const statusController: AppController = async (c) => {
});
if (event) {
return c.json(await renderStatus(event, { viewerPubkey: c.get('pubkey') }));
return c.json(await renderStatus(event, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
}
return c.json({ error: 'Event not found.' }, 404);
@@ -56,6 +58,7 @@ const statusController: AppController = async (c) => {
const createStatusController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body);
const kysely = await DittoDB.getInstance();
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 400);
@@ -89,45 +92,58 @@ const createStatusController: AppController = async (c) => {
tags.push(['subject', data.spoiler_text]);
}
if (data.media_ids?.length) {
const media = await getUnattachedMediaByIds(data.media_ids)
.then((media) => media.filter(({ pubkey }) => pubkey === c.get('pubkey')))
.then((media) => media.map(({ url, data }) => ['media', url, data]));
const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
tags.push(...media);
}
const imeta: string[][] = media.map(({ data }) => {
const values: string[] = data.map((tag) => tag.join(' '));
return ['imeta', ...values];
});
tags.push(...imeta);
const pubkeys = new Set<string>();
const content = await asyncReplaceAll(data.status ?? '', /@([\w@+._]+)/g, async (match, username) => {
const pubkey = await lookupPubkey(username);
if (!pubkey) return match;
// Content addressing (default)
if (!data.to) {
pubkeys.add(pubkey);
}
try {
const result = nip19.decode(username);
if (result.type === 'npub') {
tags.push(['p', result.data]);
return `nostr:${username}`;
} else {
return match;
}
} catch (_e) {
// do nothing
return `nostr:${nip19.npubEncode(pubkey)}`;
} catch {
return match;
}
if (NIP05.regex().test(username)) {
const pointer = await nip05Cache.fetch(username);
if (pointer) {
tags.push(['p', pointer.pubkey]);
return `nostr:${nip19.npubEncode(pointer.pubkey)}`;
}
}
return match;
});
// Explicit addressing
for (const to of data.to ?? []) {
const pubkey = await lookupPubkey(to);
if (pubkey) {
pubkeys.add(pubkey);
}
}
for (const pubkey of pubkeys) {
tags.push(['p', pubkey]);
}
for (const match of content.matchAll(/#(\w+)/g)) {
tags.push(['t', match[1]]);
}
const mediaUrls: string[] = media
.map(({ data }) => data.find(([name]) => name === 'url')?.[1])
.filter((url): url is string => Boolean(url));
const mediaCompat: string = mediaUrls.length ? ['', '', ...mediaUrls].join('\n') : '';
const event = await createEvent({
kind: 1,
content,
content: content + mediaCompat,
tags,
}, c);
@@ -136,17 +152,17 @@ const createStatusController: AppController = async (c) => {
if (data.quote_id) {
await hydrateEvents({
events: [event],
storage: eventsDB,
store: await Storages.db(),
signal: c.req.raw.signal,
});
}
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: c.get('pubkey') }));
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
};
const deleteStatusController: AppController = async (c) => {
const id = c.req.param('id');
const pubkey = c.get('pubkey');
const pubkey = await c.get('signer')?.getPublicKey();
const event = await getEvent(id, { signal: c.req.raw.signal });
@@ -170,9 +186,12 @@ const deleteStatusController: AppController = async (c) => {
const contextController: AppController = async (c) => {
const id = c.req.param('id');
const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
const viewerPubkey = await c.get('signer')?.getPublicKey();
async function renderStatuses(events: NostrEvent[]) {
const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })));
const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })),
);
return statuses.filter(Boolean);
}
@@ -202,7 +221,7 @@ const favouriteController: AppController = async (c) => {
],
}, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') });
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
if (status) {
status.favourited = true;
@@ -241,11 +260,11 @@ const reblogStatusController: AppController = async (c) => {
await hydrateEvents({
events: [reblogEvent],
storage: eventsDB,
store: await Storages.db(),
signal: signal,
});
const status = await renderReblog(reblogEvent, { viewerPubkey: c.get('pubkey') });
const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() });
return c.json(status);
};
@@ -253,23 +272,28 @@ const reblogStatusController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */
const unreblogStatusController: AppController = async (c) => {
const eventId = c.req.param('id');
const pubkey = c.get('pubkey') as string;
const pubkey = await c.get('signer')?.getPublicKey()!;
const store = await Storages.db();
const event = await getEvent(eventId, {
kind: 1,
});
if (!event) return c.json({ error: 'Event not found.' }, 404);
const [event] = await store.query([{ ids: [eventId], kinds: [1] }]);
if (!event) {
return c.json({ error: 'Record not found' }, 404);
}
const filters: NostrFilter[] = [{ kinds: [6], authors: [pubkey], '#e': [event.id] }];
const [repostedEvent] = await eventsDB.query(filters, { limit: 1 });
if (!repostedEvent) return c.json({ error: 'Event not found.' }, 404);
const [repostedEvent] = await store.query(
[{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
);
if (!repostedEvent) {
return c.json({ error: 'Record not found' }, 404);
}
await createEvent({
kind: 5,
tags: [['e', repostedEvent.id]],
}, c);
return c.json(await renderStatus(event, {}));
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
};
const rebloggedByController: AppController = (c) => {
@@ -280,7 +304,7 @@ const rebloggedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@@ -290,7 +314,7 @@ const bookmarkController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10003], authors: [pubkey] },
{ kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', eventId]),
c,
);
@@ -307,7 +331,7 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@@ -317,7 +341,7 @@ const unbookmarkController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10003], authors: [pubkey] },
{ kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', eventId]),
c,
);
@@ -334,7 +358,7 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@@ -344,7 +368,7 @@ const pinController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10001], authors: [pubkey] },
{ kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', eventId]),
c,
);
@@ -361,7 +385,7 @@ const pinController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unpin */
const unpinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const { signal } = c.req.raw;
@@ -373,7 +397,7 @@ const unpinController: AppController = async (c) => {
if (event) {
await updateListEvent(
{ kinds: [10001], authors: [pubkey] },
{ kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', eventId]),
c,
);
@@ -405,7 +429,7 @@ const zapController: AppController = async (c) => {
const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'], signal });
const author = target?.author;
const meta = jsonMetaContentSchema.parse(author?.content);
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta);
if (target && lnurl) {
@@ -421,7 +445,7 @@ const zapController: AppController = async (c) => {
],
}, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') });
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
status.zapped = true;
return c.json(status);

View File

@@ -1,15 +1,15 @@
import { NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Debug } from '@/deps.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import { getFeedPubkeys } from '@/queries.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { bech32ToPubkey } from '@/utils.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { eventsDB } from '@/storages.ts';
import { Storages } from '@/storages.ts';
const debug = Debug('ditto:streaming');
@@ -69,11 +69,24 @@ const streamingController: AppController = (c) => {
if (!filter) return;
try {
for await (const msg of Storages.pubsub.req([filter], { signal: controller.signal })) {
const pubsub = await Storages.pubsub();
const optimizer = await Storages.optimizer();
for await (const msg of pubsub.req([filter], { signal: controller.signal })) {
if (msg[0] === 'EVENT') {
const [event] = await hydrateEvents({
events: [msg[2]],
storage: eventsDB,
const event = msg[2];
if (pubkey) {
const policy = new MuteListPolicy(pubkey, await Storages.admin());
const [, , ok] = await policy.call(event);
if (!ok) {
continue;
}
}
await hydrateEvents({
events: [event],
store: optimizer,
signal: AbortSignal.timeout(1000),
});

View File

@@ -0,0 +1,51 @@
import { NStore } from '@nostrify/nostrify';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getTagSet } from '@/tags.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => {
const store = c.get('store');
const signal = c.req.raw.signal;
const accounts = await renderSuggestedAccounts(store, signal);
return c.json(accounts);
};
export const suggestionsV2Controller: AppController = async (c) => {
const store = c.get('store');
const signal = c.req.raw.signal;
const accounts = await renderSuggestedAccounts(store, signal);
const suggestions = accounts.map((account) => ({
source: 'staff',
account,
}));
return c.json(suggestions);
};
async function renderSuggestedAccounts(store: NStore, signal?: AbortSignal) {
const [follows] = await store.query(
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }],
{ signal },
);
// TODO: pagination
const pubkeys = [...getTagSet(follows?.tags ?? [], 'p')].slice(0, 20);
const profiles = await store.query(
[{ kinds: [0], authors: pubkeys, limit: pubkeys.length }],
{ signal },
)
.then((events) => hydrateEvents({ events, store, signal }));
const accounts = await Promise.all(pubkeys.map((pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey);
return profile ? renderAccount(profile) : accountFromPubkey(pubkey);
}));
return accounts.filter(Boolean);
}

View File

@@ -1,4 +1,4 @@
import { NostrFilter, NStore } from '@nostrify/nostrify';
import { NostrFilter } from '@nostrify/nostrify';
import { z } from 'zod';
import { type AppContext, type AppController } from '@/app.ts';
@@ -11,7 +11,7 @@ import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
const homeTimelineController: AppController = async (c) => {
const params = paginationSchema.parse(c.req.query());
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const authors = await getFeedPubkeys(pubkey);
return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]);
};
@@ -37,7 +37,7 @@ const publicTimelineController: AppController = (c) => {
};
const hashtagTimelineController: AppController = (c) => {
const hashtag = c.req.param('hashtag')!;
const hashtag = c.req.param('hashtag')!.toLowerCase();
const params = paginationSchema.parse(c.req.query());
return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]);
};
@@ -45,28 +45,24 @@ const hashtagTimelineController: AppController = (c) => {
/** Render statuses for timelines. */
async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const { signal } = c.req.raw;
const store = c.get('store') as NStore;
const store = c.get('store');
const events = await store
.query(filters, { signal })
.then((events) =>
hydrateEvents({
events,
storage: store,
signal,
})
);
.then((events) => hydrateEvents({ events, store, signal }));
if (!events.length) {
return c.json([]);
}
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = (await Promise.all(events.map((event) => {
if (event.kind === 6) {
return renderReblog(event, { viewerPubkey: c.get('pubkey') });
return renderReblog(event, { viewerPubkey });
}
return renderStatus(event, { viewerPubkey: c.get('pubkey') });
}))).filter((boolean) => boolean);
return renderStatus(event, { viewerPubkey });
}))).filter(Boolean);
if (!statuses.length) {
return c.json([]);