mirror of
https://github.com/aljazceru/ditto.git
synced 2025-12-26 17:54:28 +01:00
Merge remote-tracking branch 'origin/main' into refactor-trends
This commit is contained in:
@@ -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 };
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user