Merge remote-tracking branch 'origin/main' into refactor-trends

This commit is contained in:
Alex Gleason
2024-05-21 16:45:58 -05:00
65 changed files with 970 additions and 782 deletions

View File

@@ -1,28 +0,0 @@
import { getAuthor } from '@/queries.ts';
import { activityJson } from '@/utils/api.ts';
import { renderActor } from '@/views/activitypub/actor.ts';
import { localNip05Lookup } from '@/utils/nip05.ts';
import type { AppContext, AppController } from '@/app.ts';
const actorController: AppController = async (c) => {
const username = c.req.param('username');
const { signal } = c.req.raw;
const pointer = await localNip05Lookup(c.get('store'), username);
if (!pointer) return notFound(c);
const event = await getAuthor(pointer.pubkey, { signal });
if (!event) return notFound(c);
const actor = await renderActor(event, username);
if (!actor) return notFound(c);
return activityJson(c, actor);
};
function notFound(c: AppContext) {
return c.json({ error: 'Not found' }, 404);
}
export { actorController };

View File

@@ -7,8 +7,7 @@ import { Conf } from '@/config.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.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,6 +17,7 @@ import { renderRelationship } from '@/views/mastodon/relationships.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { bech32ToPubkey } from '@/utils.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
const usernameSchema = z
.string().min(1).max(30)
@@ -45,14 +45,32 @@ const createAccountController: AppController = async (c) => {
};
const verifyCredentialsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const event = await getAuthor(pubkey, { relations: ['author_stats'] });
if (event) {
return c.json(await renderAccount(event, { withSource: true }));
} else {
return c.json(await accountFromPubkey(pubkey, { withSource: true }));
const eventsDB = await Storages.db();
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,
}]),
]);
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) => {
@@ -86,25 +104,35 @@ const accountLookupController: AppController = async (c) => {
}
};
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),
store.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,
store,
signal: c.req.raw.signal,
signal,
});
if ((results.length < 1) && query.match(/npub1\w+/)) {
@@ -198,10 +226,12 @@ const updateCredentialsSchema = z.object({
bot: z.boolean().optional(),
discoverable: z.boolean().optional(),
nip05: z.string().optional(),
pleroma_settings_store: z.unknown().optional(),
});
const updateCredentialsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const body = await parseBody(c.req.raw);
const result = updateCredentialsSchema.safeParse(body);
@@ -221,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;
@@ -238,6 +268,18 @@ const updateCredentialsController: AppController = async (c) => {
}, 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(settingsStore)),
}, c);
}
account.pleroma.settings_store = settingsStore;
return c.json(account);
};

View File

@@ -5,8 +5,8 @@ import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts';
import { addTag } from '@/tags.ts';
import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts';
import { addTag } from '@/utils/tags.ts';
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
const adminAccountQuerySchema = z.object({

View File

@@ -1,6 +1,6 @@
import { type AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { getTagSet } from '@/utils/tags.ts';
import { renderStatuses } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/bookmarks/#get */

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,
@@ -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

@@ -1,6 +1,6 @@
import { type AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/tags.ts';
import { getTagSet } from '@/utils/tags.ts';
import { renderAccounts } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/mutes/#get */

View File

@@ -1,9 +1,9 @@
import { encodeBase64 } from '@std/encoding/base64';
import { escape } from 'entities';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
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';
@@ -100,11 +100,11 @@ const oauthController: AppController = async (c) => {
<form id="oauth_form" action="/oauth/authorize" method="post">
<input type="text" placeholder="npub1... or nsec1..." name="nip19" autocomplete="off">
<input type="hidden" name="pubkey" id="pubkey" value="">
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${lodash.escape(redirectUri)}">
<input type="hidden" name="redirect_uri" id="redirect_uri" value="${escape(redirectUri)}">
<button type="submit">Authorize</button>
</form>
<br>
<a href="${lodash.escape(connectUri)}">Nostr Connect</a>
<a href="${escape(connectUri)}">Nostr Connect</a>
</body>
</html>
`);

View File

@@ -1,20 +1,22 @@
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 { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.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 { asyncReplaceAll } from '@/utils/text.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
const createStatusSchema = z.object({
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
@@ -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);
@@ -73,12 +76,21 @@ const createStatusController: AppController = async (c) => {
const tags: string[][] = [];
if (data.quote_id) {
tags.push(['q', data.quote_id]);
if (data.in_reply_to_id) {
const ancestor = await getEvent(data.in_reply_to_id);
if (!ancestor) {
return c.json({ error: 'Original post not found.' }, 404);
}
const root = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
tags.push(['e', root, 'root']);
tags.push(['e', data.in_reply_to_id, 'reply']);
}
if (data.in_reply_to_id) {
tags.push(['e', data.in_reply_to_id, 'reply']);
if (data.quote_id) {
tags.push(['q', data.quote_id]);
}
if (data.sensitive && data.spoiler_text) {
@@ -89,15 +101,14 @@ const createStatusController: AppController = async (c) => {
tags.push(['subject', data.spoiler_text]);
}
const viewerPubkey = await c.get('signer')?.getPublicKey();
const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : [];
if (data.media_ids?.length) {
const media = await getUnattachedMediaByIds(data.media_ids)
.then((media) => media.filter(({ pubkey }) => pubkey === viewerPubkey))
.then((media) => media.map(({ url, data }) => ['media', url, data]));
const imeta: string[][] = media.map(({ data }) => {
const values: string[] = data.map((tag) => tag.join(' '));
return ['imeta', ...values];
});
tags.push(...media);
}
tags.push(...imeta);
const pubkeys = new Set<string>();
@@ -110,7 +121,11 @@ const createStatusController: AppController = async (c) => {
pubkeys.add(pubkey);
}
return `nostr:${pubkey}`;
try {
return `nostr:${nip19.npubEncode(pubkey)}`;
} catch {
return match;
}
});
// Explicit addressing
@@ -129,9 +144,15 @@ const createStatusController: AppController = async (c) => {
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);
@@ -261,21 +282,19 @@ const reblogStatusController: AppController = async (c) => {
const unreblogStatusController: AppController = async (c) => {
const eventId = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey()!;
const event = await getEvent(eventId, { kind: 1 });
if (!event) {
return c.json({ error: 'Event not found.' }, 404);
}
const store = await Storages.db();
const [event] = await store.query([{ ids: [eventId], kinds: [1] }]);
if (!event) {
return c.json({ error: 'Record not found' }, 404);
}
const [repostedEvent] = await store.query(
[{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
);
if (!repostedEvent) {
return c.json({ error: 'Event not found.' }, 404);
return c.json({ error: 'Record not found' }, 404);
}
await createEvent({

View File

@@ -2,8 +2,8 @@ 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 { getTagSet } from '@/utils/tags.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => {

View File

@@ -1,20 +0,0 @@
import { Conf } from '@/config.ts';
import type { AppController } from '@/app.ts';
/** https://datatracker.ietf.org/doc/html/rfc6415 */
const hostMetaController: AppController = (c) => {
const template = Conf.local('/.well-known/webfinger?resource={uri}');
c.header('content-type', 'application/xrd+xml');
return c.body(
`<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="${template}" type="application/xrd+xml" />
</XRD>
`,
);
};
export { hostMetaController };

View File

@@ -1,97 +0,0 @@
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { Conf } from '@/config.ts';
import { localNip05Lookup } from '@/utils/nip05.ts';
import type { AppContext, AppController } from '@/app.ts';
import type { Webfinger } from '@/schemas/webfinger.ts';
const webfingerQuerySchema = z.object({
resource: z.string().url(),
});
const webfingerController: AppController = (c) => {
const query = webfingerQuerySchema.safeParse(c.req.query());
if (!query.success) {
return c.json({ error: 'Bad request', schema: query.error }, 400);
}
const resource = new URL(query.data.resource);
switch (resource.protocol) {
case 'acct:': {
return handleAcct(c, resource);
}
default:
return c.json({ error: 'Unsupported URI scheme' }, 400);
}
};
/** Transforms the resource URI into a `[username, domain]` tuple. */
const acctSchema = z.custom<URL>((value) => value instanceof URL)
.transform((uri) => uri.pathname)
.pipe(z.string().email('Invalid acct'))
.transform((acct) => acct.split('@') as [username: string, host: string])
.refine(([_username, host]) => host === Conf.url.hostname, {
message: 'Host must be local',
path: ['resource', 'acct'],
});
async function handleAcct(c: AppContext, resource: URL): Promise<Response> {
const result = acctSchema.safeParse(resource);
if (!result.success) {
return c.json({ error: 'Invalid acct URI', schema: result.error }, 400);
}
const [username, host] = result.data;
const pointer = await localNip05Lookup(c.get('store'), username);
if (!pointer) {
return c.json({ error: 'Not found' }, 404);
}
const json = renderWebfinger({
pubkey: pointer.pubkey,
username,
subject: `acct:${username}@${host}`,
});
c.header('content-type', 'application/jrd+json');
return c.body(JSON.stringify(json));
}
interface RenderWebfingerOpts {
pubkey: string;
username: string;
subject: string;
}
/** Present Nostr user on Webfinger. */
function renderWebfinger({ pubkey, username, subject }: RenderWebfingerOpts): Webfinger {
const apId = Conf.local(`/users/${username}`);
return {
subject,
aliases: [apId],
links: [
{
rel: 'self',
type: 'application/activity+json',
href: apId,
},
{
rel: 'self',
type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
href: apId,
},
{
rel: 'self',
type: 'application/nostr+json',
href: `nostr:${nip19.npubEncode(pubkey)}`,
},
],
};
}
export { webfingerController };