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/activitypub/actor.ts b/src/controllers/activitypub/actor.ts index 549f08d..6c9ed02 100644 --- a/src/controllers/activitypub/actor.ts +++ b/src/controllers/activitypub/actor.ts @@ -1,7 +1,7 @@ import { findUser } from '@/db/users.ts'; import { getAuthor } from '@/queries.ts'; import { toActor } from '@/transformers/nostr-to-activitypub.ts'; -import { activityJson } from '@/utils.ts'; +import { activityJson } from '@/utils/web.ts'; import type { AppContext, AppController } from '@/app.ts'; diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index da464ef..febf881 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -3,10 +3,13 @@ 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 { eventDateComparator, isFollowing, lookupAccount, nostrNow } from '@/utils.ts'; +import { buildLinkHeader, paginationSchema, parseBody } from '@/utils/web.ts'; +import { createEvent } from '@/utils/web.ts'; const createAccountController: AppController = (c) => { return c.json({ error: 'Please log in with Nostr.' }, 405); @@ -72,29 +75,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 +164,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/controllers/api/oauth.ts b/src/controllers/api/oauth.ts index db0597f..830f433 100644 --- a/src/controllers/api/oauth.ts +++ b/src/controllers/api/oauth.ts @@ -1,6 +1,7 @@ import { lodash, nip19, uuid62, z } from '@/deps.ts'; import { AppController } from '@/app.ts'; -import { nostrNow, parseBody } from '@/utils.ts'; +import { nostrNow } from '@/utils.ts'; +import { parseBody } from '@/utils/web.ts'; const passwordGrantSchema = z.object({ grant_type: z.literal('password'), diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index aae7f6c..afb049a 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -4,7 +4,8 @@ import * as pipeline from '@/pipeline.ts'; import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; import { signEvent } from '@/sign.ts'; import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; -import { nostrNow, parseBody } from '@/utils.ts'; +import { nostrNow } from '@/utils.ts'; +import { parseBody } from '@/utils/web.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index 6a442c1..6470e43 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -2,7 +2,7 @@ import { z } from '@/deps.ts'; import { getFeed, getPublicFeed } from '@/queries.ts'; import { booleanParamSchema } from '@/schema.ts'; import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; -import { buildLinkHeader, paginationSchema } from '@/utils.ts'; +import { buildLinkHeader, paginationSchema } from '@/utils/web.ts'; import type { AppController } from '@/app.ts'; diff --git a/src/queries.ts b/src/queries.ts index 9e17008..6b73a8b 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,7 +1,7 @@ import * as eventsDB from '@/db/events.ts'; import { type Event, type Filter, findReplyTag } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; -import { type PaginationParams } from '@/utils.ts'; +import { type PaginationParams } from '@/utils/web.ts'; interface GetEventOpts { /** Timeout in milliseconds. */ @@ -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..653c824 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ -import { Conf } from '@/config.ts'; -import { type Context, type Event, nip19, parseFormData, z } from '@/deps.ts'; +import { type Event, nip19, z } from '@/deps.ts'; import { lookupNip05Cached } from '@/nip05.ts'; import { getAuthor } from '@/queries.ts'; @@ -66,40 +65,6 @@ async function lookupAccount(value: string): Promise | undefined> { } } -/** Parse request body to JSON, depending on the content-type of the request. */ -async function parseBody(req: Request): Promise { - switch (req.headers.get('content-type')?.split(';')[0]) { - case 'multipart/form-data': - case 'application/x-www-form-urlencoded': - return parseFormData(await req.formData()); - case 'application/json': - return req.json(); - } -} - -const paginationSchema = z.object({ - since: z.coerce.number().optional().catch(undefined), - until: z.lazy(() => z.coerce.number().catch(nostrNow())), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), -}); - -type PaginationParams = z.infer; - -function buildLinkHeader(url: string, events: Event[]): string | undefined { - if (!events.length) return; - const firstEvent = events[0]; - const lastEvent = events[events.length - 1]; - - const { pathname, search } = new URL(url); - const next = new URL(pathname + search, Conf.localDomain); - const prev = new URL(pathname + search, Conf.localDomain); - - next.searchParams.set('until', String(lastEvent.created_at)); - prev.searchParams.set('since', String(firstEvent.created_at)); - - return `<${next}>; rel="next", <${prev}>; rel="prev"`; -} - /** Return the event's age in milliseconds. */ function eventAge(event: Event): number { return new Date().getTime() - nostrDate(event.created_at).getTime(); @@ -123,45 +88,30 @@ async function sha256(message: string): Promise { return hashHex; } -/** JSON-LD context. */ -type LDContext = (string | Record>)[]; - -/** Add a basic JSON-LD context to ActivityStreams object, if it doesn't already exist. */ -function maybeAddContext(object: T): T & { '@context': LDContext } { - return { - '@context': ['https://www.w3.org/ns/activitystreams'], - ...object, - }; -} - -/** Like hono's `c.json()` except returns JSON-LD. */ -function activityJson(c: Context, object: T) { - const response = c.json(maybeAddContext(object)); - response.headers.set('content-type', 'application/activity+json; charset=UTF-8'); - return response; -} - /** Schema to parse a relay URL. */ 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, - buildLinkHeader, eventAge, eventDateComparator, findTag, + isFollowing, isRelay, lookupAccount, type Nip05, nostrDate, nostrNow, - type PaginationParams, - paginationSchema, - parseBody, parseNip05, relaySchema, sha256, diff --git a/src/utils/web.ts b/src/utils/web.ts new file mode 100644 index 0000000..5df153e --- /dev/null +++ b/src/utils/web.ts @@ -0,0 +1,90 @@ +import { Conf } from '@/config.ts'; +import { type Context, type Event, EventTemplate, HTTPException, parseFormData, z } 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; +} + +/** Parse request body to JSON, depending on the content-type of the request. */ +async function parseBody(req: Request): Promise { + switch (req.headers.get('content-type')?.split(';')[0]) { + case 'multipart/form-data': + case 'application/x-www-form-urlencoded': + return parseFormData(await req.formData()); + case 'application/json': + return req.json(); + } +} + +/** Schema to parse pagination query params. */ +const paginationSchema = z.object({ + since: z.coerce.number().optional().catch(undefined), + until: z.lazy(() => z.coerce.number().catch(nostrNow())), + limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), +}); + +/** Mastodon API pagination query params. */ +type PaginationParams = z.infer; + +/** Build HTTP Link header for Mastodon API pagination. */ +function buildLinkHeader(url: string, events: Event[]): string | undefined { + if (!events.length) return; + const firstEvent = events[0]; + const lastEvent = events[events.length - 1]; + + const { pathname, search } = new URL(url); + const next = new URL(pathname + search, Conf.localDomain); + const prev = new URL(pathname + search, Conf.localDomain); + + next.searchParams.set('until', String(lastEvent.created_at)); + prev.searchParams.set('since', String(firstEvent.created_at)); + + return `<${next}>; rel="next", <${prev}>; rel="prev"`; +} + +/** JSON-LD context. */ +type LDContext = (string | Record>)[]; + +/** Add a basic JSON-LD context to ActivityStreams object, if it doesn't already exist. */ +function maybeAddContext(object: T): T & { '@context': LDContext } { + return { + '@context': ['https://www.w3.org/ns/activitystreams'], + ...object, + }; +} + +/** Like hono's `c.json()` except returns JSON-LD. */ +function activityJson(c: Context, object: T) { + const response = c.json(maybeAddContext(object)); + response.headers.set('content-type', 'application/activity+json; charset=UTF-8'); + return response; +} + +export { activityJson, buildLinkHeader, createEvent, type PaginationParams, paginationSchema, parseBody };