diff --git a/src/app.ts b/src/app.ts index 39df1a7..cc99474 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ import { accountSearchController, accountStatusesController, createAccountController, + followController, relationshipsController, updateCredentialsController, verifyCredentialsController, @@ -98,6 +99,7 @@ app.patch('/api/v1/accounts/update_credentials', requireAuth, updateCredentialsC app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/lookup', accountLookupController); app.get('/api/v1/accounts/relationships', relationshipsController); +app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', followController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/statuses', accountStatusesController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}', accountController); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index da464ef..84b3ce2 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -3,10 +3,20 @@ import { type Filter, findReplyTag, z } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; import * as pipeline from '@/pipeline.ts'; import { getAuthor, getFollows } from '@/queries.ts'; +import { booleanParamSchema } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { signEvent } from '@/sign.ts'; -import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; -import { buildLinkHeader, eventDateComparator, lookupAccount, nostrNow, paginationSchema, parseBody } from '@/utils.ts'; +import { toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; +import { + buildLinkHeader, + eventDateComparator, + isFollowing, + lookupAccount, + nostrNow, + paginationSchema, + parseBody, +} from '@/utils.ts'; +import { createEvent } from '@/utils/web.ts'; const createAccountController: AppController = (c) => { return c.json({ error: 'Please log in with Nostr.' }, 405); @@ -72,29 +82,11 @@ const relationshipsController: AppController = async (c) => { return c.json({ error: 'Missing `id[]` query parameters.' }, 422); } - const follows = await getFollows(pubkey); - - const result = ids.data.map((id) => ({ - id, - following: !!follows?.tags.find((tag) => tag[0] === 'p' && ids.data.includes(tag[1])), - showing_reblogs: false, - notifying: false, - followed_by: false, - blocking: false, - blocked_by: false, - muting: false, - muting_notifications: false, - requested: false, - domain_blocking: false, - endorsed: false, - })); + const result = await Promise.all(ids.data.map((id) => toRelationship(pubkey, id))); return c.json(result); }; -/** https://github.com/colinhacks/zod/issues/1630#issuecomment-1365983831 */ -const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value === 'true'); - const accountStatusesQuerySchema = z.object({ pinned: booleanParamSchema.optional(), limit: z.coerce.number().positive().transform((v) => Math.min(v, 40)).catch(20), @@ -179,12 +171,34 @@ const updateCredentialsController: AppController = async (c) => { return c.json(account); }; +const followController: AppController = async (c) => { + const sourcePubkey = c.get('pubkey')!; + const targetPubkey = c.req.param('pubkey'); + + const source = await getFollows(sourcePubkey); + + if (!source || !isFollowing(source, targetPubkey)) { + await createEvent({ + kind: 3, + content: '', + tags: [ + ...(source?.tags ?? []), + ['p', targetPubkey], + ], + }, c); + } + + const relationship = await toRelationship(sourcePubkey, targetPubkey); + return c.json(relationship); +}; + export { accountController, accountLookupController, accountSearchController, accountStatusesController, createAccountController, + followController, relationshipsController, updateCredentialsController, verifyCredentialsController, diff --git a/src/queries.ts b/src/queries.ts index 9e17008..dc3f7f5 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -26,13 +26,13 @@ const getEvent = async ( /** Get a Nostr `set_medatadata` event for a user's pubkey. */ const getAuthor = async (pubkey: string, timeout = 1000): Promise | undefined> => { - const [event] = await mixer.getFilters([{ authors: [pubkey], kinds: [0] }], { timeout }); + const [event] = await mixer.getFilters([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, timeout }); return event; }; /** Get users the given pubkey follows. */ const getFollows = async (pubkey: string, timeout = 1000): Promise | undefined> => { - const [event] = await mixer.getFilters([{ authors: [pubkey], kinds: [3] }], { timeout }); + const [event] = await mixer.getFilters([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, timeout }); return event; }; @@ -85,7 +85,7 @@ function getDescendants(eventId: string): Promise[]> { /** Returns whether the pubkey is followed by a local user. */ async function isLocallyFollowed(pubkey: string): Promise { - const [event] = await eventsDB.getFilters([{ kinds: [3], '#p': [pubkey], local: true }], { limit: 1 }); + const [event] = await eventsDB.getFilters([{ kinds: [3], '#p': [pubkey], local: true, limit: 1 }], { limit: 1 }); return Boolean(event); } diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index 520246f..2c474e1 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -4,10 +4,10 @@ import { Conf } from '@/config.ts'; import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; import { verifyNip05Cached } from '@/nip05.ts'; import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; -import { getAuthor } from '@/queries.ts'; +import { getAuthor, getFollows } from '@/queries.ts'; import { emojiTagSchema, filteredArray } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; -import { type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; +import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png'; @@ -254,4 +254,26 @@ function toEmojis(event: Event) { })); } -export { toAccount, toStatus }; +async function toRelationship(sourcePubkey: string, targetPubkey: string) { + const [source, target] = await Promise.all([ + getFollows(sourcePubkey), + getFollows(targetPubkey), + ]); + + return { + id: targetPubkey, + following: source ? isFollowing(source, targetPubkey) : false, + showing_reblogs: true, + notifying: false, + followed_by: target ? isFollowing(target, sourcePubkey) : false, + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + domain_blocking: false, + endorsed: false, + }; +} + +export { toAccount, toRelationship, toStatus }; diff --git a/src/utils.ts b/src/utils.ts index 3075914..4c2b82f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -147,6 +147,13 @@ const relaySchema = z.string().max(255).startsWith('wss://').url(); /** Check whether the value is a valid relay URL. */ const isRelay = (relay: string): relay is `wss://${string}` => relaySchema.safeParse(relay).success; +/** Check whether source is following target. */ +function isFollowing(source: Event<3>, targetPubkey: string): boolean { + return Boolean( + source.tags.find(([tagName, tagValue]) => tagName === 'p' && tagValue === targetPubkey), + ); +} + export { activityJson, bech32ToPubkey, @@ -154,6 +161,7 @@ export { eventAge, eventDateComparator, findTag, + isFollowing, isRelay, lookupAccount, type Nip05, diff --git a/src/utils/web.ts b/src/utils/web.ts new file mode 100644 index 0000000..baccf85 --- /dev/null +++ b/src/utils/web.ts @@ -0,0 +1,34 @@ +import { EventTemplate, HTTPException } from '@/deps.ts'; +import * as pipeline from '@/pipeline.ts'; +import { signEvent } from '@/sign.ts'; +import { nostrNow } from '@/utils.ts'; + +import type { AppContext } from '@/app.ts'; + +/** Publish an event through the API, throwing a Hono exception on failure. */ +async function createEvent(t: Omit, 'created_at'>, c: AppContext) { + const pubkey = c.get('pubkey'); + + if (!pubkey) { + throw new HTTPException(401); + } + + const event = await signEvent({ + created_at: nostrNow(), + ...t, + }, c); + + try { + await pipeline.handleEvent(event); + } catch (e) { + if (e instanceof pipeline.RelayError) { + throw new HTTPException(422, { + res: c.json({ error: e.message }, 422), + }); + } + } + + return event; +} + +export { createEvent };