diff --git a/src/controllers/activitypub/actor.ts b/src/controllers/activitypub/actor.ts index 6c9ed02..49fda36 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/web.ts'; +import { renderActor } from '@/views/activitypub/actor.ts'; import type { AppContext, AppController } from '@/app.ts'; @@ -14,7 +14,7 @@ const actorController: AppController = async (c) => { const event = await getAuthor(user.pubkey); if (!event) return notFound(c); - const actor = await toActor(event, user.username); + const actor = await renderActor(event, user.username); if (!actor) return notFound(c); return activityJson(c, actor); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index f14efaf..9ac9f57 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -1,17 +1,19 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; +import { insertUser } from '@/db/users.ts'; import { type Filter, findReplyTag, nip19, z } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; -import { accountFromPubkey, toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; +import { uploadFile } from '@/upload.ts'; import { isFollowing, lookupAccount, nostrNow, Time } from '@/utils.ts'; import { paginated, paginationSchema, parseBody } from '@/utils/web.ts'; import { createEvent } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; -import { insertUser } from '@/db/users.ts'; -import { uploadFile } from '@/upload.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { renderRelationship } from '@/views/mastodon/relationships.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; const usernameSchema = z .string().min(1).max(30) @@ -60,7 +62,7 @@ const verifyCredentialsController: AppController = async (c) => { const event = await getAuthor(pubkey); if (event) { - return c.json(await toAccount(event, { withSource: true })); + return c.json(await renderAccount(event, { withSource: true })); } else { return c.json(await accountFromPubkey(pubkey, { withSource: true })); } @@ -71,7 +73,7 @@ const accountController: AppController = async (c) => { const event = await getAuthor(pubkey); if (event) { - return c.json(await toAccount(event)); + return c.json(await renderAccount(event)); } return c.json({ error: 'Could not find user.' }, 404); @@ -86,7 +88,7 @@ const accountLookupController: AppController = async (c) => { const event = await lookupAccount(decodeURIComponent(acct)); if (event) { - return c.json(await toAccount(event)); + return c.json(await renderAccount(event)); } return c.json({ error: 'Could not find user.' }, 404); @@ -101,7 +103,7 @@ const accountSearchController: AppController = async (c) => { const event = await lookupAccount(decodeURIComponent(q)); if (event) { - return c.json([await toAccount(event)]); + return c.json([await renderAccount(event)]); } return c.json([]); @@ -115,7 +117,7 @@ const relationshipsController: AppController = async (c) => { return c.json({ error: 'Missing `id[]` query parameters.' }, 422); } - const result = await Promise.all(ids.data.map((id) => toRelationship(pubkey, id))); + const result = await Promise.all(ids.data.map((id) => renderRelationship(pubkey, id))); return c.json(result); }; @@ -148,7 +150,7 @@ const accountStatusesController: AppController = async (c) => { events = events.filter((event) => !findReplyTag(event)); } - const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); return paginated(c, events, statuses); }; @@ -199,7 +201,7 @@ const updateCredentialsController: AppController = async (c) => { tags: [], }, c); - const account = await toAccount(event); + const account = await renderAccount(event); return c.json(account); }; @@ -220,7 +222,7 @@ const followController: AppController = async (c) => { }, c); } - const relationship = await toRelationship(sourcePubkey, targetPubkey); + const relationship = await renderRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; @@ -237,7 +239,7 @@ const followingController: AppController = async (c) => { // TODO: pagination by offset. const accounts = await Promise.all(pubkeys.map(async (pubkey) => { const event = await getAuthor(pubkey); - return event ? await toAccount(event) : undefined; + return event ? await renderAccount(event) : undefined; })); return c.json(accounts.filter(Boolean)); @@ -258,7 +260,7 @@ const favouritesController: AppController = async (c) => { const events1 = await mixer.getFilters([{ kinds: [1], ids }], { timeout: Time.seconds(1) }); - const statuses = await Promise.all(events1.map((event) => toStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey')))); return paginated(c, events1, statuses); }; diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index a7edef1..b35da9f 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -2,7 +2,7 @@ import { AppController } from '@/app.ts'; import { z } from '@/deps.ts'; import { fileSchema } from '@/schema.ts'; import { parseBody } from '@/utils/web.ts'; -import { renderAttachment } from '@/views/attachment.ts'; +import { renderAttachment } from '@/views/mastodon/attachments.ts'; import { uploadFile } from '@/upload.ts'; const mediaBodySchema = z.object({ diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index bd8fe4d..89f59f7 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -1,8 +1,8 @@ import { type AppController } from '@/app.ts'; import * as mixer from '@/mixer.ts'; -import { paginated, paginationSchema } from '@/utils/web.ts'; -import { toNotification } from '@/transformers/nostr-to-mastoapi.ts'; import { Time } from '@/utils.ts'; +import { paginated, paginationSchema } from '@/utils/web.ts'; +import { renderNotification } from '@/views/mastodon/notifications.ts'; const notificationsController: AppController = async (c) => { const pubkey = c.get('pubkey')!; @@ -13,7 +13,7 @@ const notificationsController: AppController = async (c) => { { timeout: Time.seconds(3) }, ); - const statuses = await Promise.all(events.map((event) => toNotification(event, pubkey))); + const statuses = await Promise.all(events.map((event) => renderNotification(event, pubkey))); return paginated(c, events, statuses); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index ee8497e..15417c8 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -4,9 +4,10 @@ import { type Event, type Filter, nip19, z } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; import { booleanParamSchema } from '@/schema.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts'; -import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { dedupeEvents, Time } from '@/utils.ts'; import { lookupNip05Cached } from '@/utils/nip05.ts'; +import { renderAccount } from '@/views/mastodon/accounts.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; /** Matches NIP-05 names with or without an @ in front. */ const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; @@ -44,12 +45,12 @@ const searchController: AppController = async (c) => { Promise.all( results .filter((event): event is Event<0> => event.kind === 0) - .map((event) => toAccount(event)), + .map((event) => renderAccount(event)), ), Promise.all( results .filter((event): event is Event<1> => event.kind === 1) - .map((event) => toStatus(event, c.get('pubkey'))), + .map((event) => renderStatus(event, c.get('pubkey'))), ), ]); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 4756df0..0f6be9c 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,10 +1,10 @@ import { type AppController } from '@/app.ts'; +import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; -import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; -import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), @@ -31,7 +31,7 @@ const statusController: AppController = async (c) => { const event = await getEvent(id, { kind: 1 }); if (event) { - return c.json(await toStatus(event, c.get('pubkey'))); + return c.json(await renderStatus(event, c.get('pubkey'))); } return c.json({ error: 'Event not found.' }, 404); @@ -83,7 +83,7 @@ const createStatusController: AppController = async (c) => { tags, }, c); - return c.json(await toStatus(event, c.get('pubkey'))); + return c.json(await renderStatus(event, c.get('pubkey'))); }; const contextController: AppController = async (c) => { @@ -91,7 +91,7 @@ const contextController: AppController = async (c) => { const event = await getEvent(id, { kind: 1 }); async function renderStatuses(events: Event<1>[]) { - const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); return statuses.filter(Boolean); } @@ -121,7 +121,7 @@ const favouriteController: AppController = async (c) => { ], }, c); - const status = await toStatus(target, c.get('pubkey')); + const status = await renderStatus(target, c.get('pubkey')); if (status) { status.favourited = true; diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index acbc545..6a9e535 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -3,8 +3,8 @@ import { z } from '@/deps.ts'; import { type DittoFilter } from '@/filter.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { Sub } from '@/subs.ts'; -import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { bech32ToPubkey } from '@/utils.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; /** * Streaming timelines/categories. @@ -63,7 +63,7 @@ const streamingController: AppController = (c) => { if (filter) { for await (const event of Sub.sub(socket, '1', [filter])) { - const status = await toStatus(event, pubkey); + const status = await renderStatus(event, pubkey); if (status) { send('update', status); } diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index 4b1825f..712f741 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -3,9 +3,9 @@ import { type DittoFilter } from '@/filter.ts'; import * as mixer from '@/mixer.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { booleanParamSchema } from '@/schema.ts'; -import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; -import { paginated, paginationSchema } from '@/utils/web.ts'; import { Time } from '@/utils.ts'; +import { paginated, paginationSchema } from '@/utils/web.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; import type { AppContext, AppController } from '@/app.ts'; @@ -40,7 +40,7 @@ async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) { return c.json([]); } - const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); return paginated(c, events, statuses); } diff --git a/src/note.ts b/src/note.ts index 92baf52..93689e9 100644 --- a/src/note.ts +++ b/src/note.ts @@ -1,6 +1,6 @@ import { Conf } from '@/config.ts'; import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts'; -import { type DittoAttachment } from '@/views/attachment.ts'; +import { type DittoAttachment } from '@/views/mastodon/attachments.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); diff --git a/src/schema.ts b/src/schema.ts index a29191f..8868cca 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -21,9 +21,6 @@ const jsonSchema = z.string().transform((value, ctx) => { } }); -/** Parses a Nostr emoji tag. */ -const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]); - /** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */ const decode64Schema = z.string().transform((value, ctx) => { try { @@ -51,13 +48,4 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value /** Schema for `File` objects. */ const fileSchema = z.custom((value) => value instanceof File); -export { - booleanParamSchema, - decode64Schema, - emojiTagSchema, - fileSchema, - filteredArray, - hashtagSchema, - jsonSchema, - safeUrlSchema, -}; +export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, jsonSchema, safeUrlSchema }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index c097935..7fb888c 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -111,6 +111,12 @@ const connectResponseSchema = z.object({ result: signedEventSchema, }); +/** Parses a Nostr emoji tag. */ +const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]); + +/** NIP-30 custom emoji tag. */ +type EmojiTag = z.infer; + export { type ClientCLOSE, type ClientCOUNT, @@ -119,6 +125,8 @@ export { clientMsgSchema, type ClientREQ, connectResponseSchema, + type EmojiTag, + emojiTagSchema, filterSchema, jsonMediaDataSchema, jsonMetaContentSchema, diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts deleted file mode 100644 index 1e3b887..0000000 --- a/src/transformers/nostr-to-mastoapi.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; - -import { Conf } from '@/config.ts'; -import * as eventsDB from '@/db/events.ts'; -import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, type UnsignedEvent } from '@/deps.ts'; -import { getMediaLinks, parseNoteContent } from '@/note.ts'; -import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; -import { emojiTagSchema, filteredArray } from '@/schema.ts'; -import { jsonMediaDataSchema, jsonMetaContentSchema } from '@/schemas/nostr.ts'; -import { isFollowing, type Nip05, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; -import { verifyNip05Cached } from '@/utils/nip05.ts'; -import { findUser } from '@/db/users.ts'; -import { DittoAttachment, renderAttachment } from '@/views/attachment.ts'; - -const defaultAvatar = () => Conf.local('/images/avi.png'); -const defaultBanner = () => Conf.local('/images/banner.png'); - -interface ToAccountOpts { - withSource?: boolean; -} - -async function toAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) { - const { withSource = false } = opts; - const { pubkey } = event; - - const { - name, - nip05, - picture = defaultAvatar(), - banner = defaultBanner(), - about, - } = jsonMetaContentSchema.parse(event.content); - - const npub = nip19.npubEncode(pubkey); - - const [user, parsed05, followersCount, followingCount, statusesCount] = await Promise.all([ - findUser({ pubkey }), - parseAndVerifyNip05(nip05, pubkey), - eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]), - getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length), - eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]), - ]); - - return { - id: pubkey, - acct: parsed05?.handle || npub, - avatar: picture, - avatar_static: picture, - bot: false, - created_at: event ? nostrDate(event.created_at).toISOString() : new Date().toISOString(), - discoverable: true, - display_name: name, - emojis: toEmojis(event), - fields: [], - follow_requests_count: 0, - followers_count: followersCount, - following_count: followingCount, - fqn: parsed05?.handle || npub, - header: banner, - header_static: banner, - last_status_at: null, - locked: false, - note: lodash.escape(about), - roles: [], - source: withSource - ? { - fields: [], - language: '', - note: about || '', - privacy: 'public', - sensitive: false, - follow_requests_count: 0, - } - : undefined, - statuses_count: statusesCount, - url: Conf.local(`/users/${pubkey}`), - username: parsed05?.nickname || npub.substring(0, 8), - pleroma: { - is_admin: user?.admin || false, - is_moderator: user?.admin || false, - }, - }; -} - -function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) { - const event: UnsignedEvent<0> = { - kind: 0, - pubkey, - content: '', - tags: [], - created_at: nostrNow(), - }; - - return toAccount(event, opts); -} - -async function parseAndVerifyNip05(nip05: string | undefined, pubkey: string): Promise { - if (nip05 && await verifyNip05Cached(nip05, pubkey)) { - return parseNip05(nip05); - } -} - -async function toMention(pubkey: string) { - const profile = await getAuthor(pubkey); - const account = profile ? await toAccount(profile) : undefined; - - if (account) { - return { - id: account.id, - acct: account.acct, - username: account.username, - url: account.url, - }; - } else { - const npub = nip19.npubEncode(pubkey); - return { - id: pubkey, - acct: npub, - username: npub.substring(0, 8), - url: Conf.local(`/users/${pubkey}`), - }; - } -} - -async function toStatus(event: Event<1>, viewerPubkey?: string) { - const profile = await getAuthor(event.pubkey); - const account = profile ? await toAccount(profile) : await accountFromPubkey(event.pubkey); - - const replyTag = findReplyTag(event); - - const mentionedPubkeys = [ - ...new Set( - event.tags - .filter((tag) => tag[0] === 'p') - .map((tag) => tag[1]), - ), - ]; - - const { html, links, firstUrl } = parseNoteContent(event.content); - - const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise - .all([ - Promise.all(mentionedPubkeys.map(toMention)), - firstUrl ? unfurlCardCached(firstUrl) : null, - eventsDB.countFilters([{ kinds: [1], '#e': [event.id] }]), - eventsDB.countFilters([{ kinds: [6], '#e': [event.id] }]), - eventsDB.countFilters([{ kinds: [7], '#e': [event.id] }]), - viewerPubkey - ? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 }) - : [], - viewerPubkey - ? eventsDB.getFilters([{ kinds: [7], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 }) - : [], - ]); - - const content = buildInlineRecipients(mentions) + html; - - const cw = event.tags.find(isCWTag); - const subject = event.tags.find((tag) => tag[0] === 'subject'); - - const mediaLinks = getMediaLinks(links); - - const mediaTags: DittoAttachment[] = event.tags - .filter((tag) => tag[0] === 'media') - .map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) })); - - const media = [...mediaLinks, ...mediaTags]; - - return { - id: event.id, - account, - card, - content, - created_at: nostrDate(event.created_at).toISOString(), - in_reply_to_id: replyTag ? replyTag[1] : null, - in_reply_to_account_id: null, - sensitive: !!cw, - spoiler_text: (cw ? cw[1] : subject?.[1]) || '', - visibility: 'public', - language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null, - replies_count: repliesCount, - reblogs_count: reblogsCount, - favourites_count: favouritesCount, - favourited: reactionEvent?.content === '+', - reblogged: Boolean(repostEvent), - muted: false, - bookmarked: false, - reblog: null, - application: null, - media_attachments: media.map(renderAttachment), - mentions, - tags: [], - emojis: toEmojis(event), - poll: null, - uri: Conf.local(`/posts/${event.id}`), - url: Conf.local(`/posts/${event.id}`), - }; -} - -type Mention = Awaited>; - -function buildInlineRecipients(mentions: Mention[]): string { - if (!mentions.length) return ''; - - const elements = mentions.reduce((acc, { url, username }) => { - const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; - acc.push(`@${name}`); - return acc; - }, []); - - return `${elements.join(' ')} `; -} - -interface PreviewCard { - url: string; - title: string; - description: string; - type: 'link' | 'photo' | 'video' | 'rich'; - author_name: string; - author_url: string; - provider_name: string; - provider_url: string; - html: string; - width: number; - height: number; - image: string | null; - embed_url: string; - blurhash: string | null; -} - -async function unfurlCard(url: string): Promise { - console.log(`Unfurling ${url}...`); - try { - const result = await unfurl(url, { - fetch: (url) => fetch(url, { signal: AbortSignal.timeout(Time.seconds(1)) }), - }); - - return { - type: result.oEmbed?.type || 'link', - url: result.canonical_url || url, - title: result.oEmbed?.title || result.title || '', - description: result.open_graph.description || result.description || '', - author_name: result.oEmbed?.author_name || '', - author_url: result.oEmbed?.author_url || '', - provider_name: result.oEmbed?.provider_name || '', - provider_url: result.oEmbed?.provider_url || '', - // @ts-expect-error `html` does in fact exist on oEmbed. - html: sanitizeHtml(result.oEmbed?.html || '', { - allowedTags: ['iframe'], - allowedAttributes: { - iframe: ['width', 'height', 'src', 'frameborder', 'allowfullscreen'], - }, - }), - width: result.oEmbed?.width || 0, - height: result.oEmbed?.height || 0, - image: result.oEmbed?.thumbnails?.[0].url || result.open_graph.images?.[0].url || null, - embed_url: '', - blurhash: null, - }; - } catch (_e) { - return null; - } -} - -const previewCardCache = new TTLCache>({ ttl: Time.hours(12), max: 500 }); - -/** Unfurl card from cache if available, otherwise fetch it. */ -function unfurlCardCached(url: string): Promise { - const cached = previewCardCache.get(url); - if (cached !== undefined) return cached; - - const card = unfurlCard(url); - previewCardCache.set(url, card); - - return card; -} - -function toEmojis(event: UnsignedEvent) { - const emojiTags = event.tags.filter((tag) => tag[0] === 'emoji'); - - return filteredArray(emojiTagSchema).parse(emojiTags) - .map((tag) => ({ - shortcode: tag[1], - static_url: tag[2], - url: tag[2], - })); -} - -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, - }; -} - -function toNotification(event: Event, viewerPubkey?: string) { - switch (event.kind) { - case 1: - return toNotificationMention(event as Event<1>, viewerPubkey); - } -} - -async function toNotificationMention(event: Event<1>, viewerPubkey?: string) { - const status = await toStatus(event, viewerPubkey); - if (!status) return; - - return { - id: event.id, - type: 'mention', - created_at: nostrDate(event.created_at).toISOString(), - account: status.account, - status: status, - }; -} - -export { accountFromPubkey, toAccount, toNotification, toRelationship, toStatus }; diff --git a/src/utils/unfurl.ts b/src/utils/unfurl.ts new file mode 100644 index 0000000..80977cf --- /dev/null +++ b/src/utils/unfurl.ts @@ -0,0 +1,73 @@ +import { TTLCache, unfurl } from '@/deps.ts'; +import { Time } from '@/utils/time.ts'; + +interface PreviewCard { + url: string; + title: string; + description: string; + type: 'link' | 'photo' | 'video' | 'rich'; + author_name: string; + author_url: string; + provider_name: string; + provider_url: string; + html: string; + width: number; + height: number; + image: string | null; + embed_url: string; + blurhash: string | null; +} + +async function unfurlCard(url: string, signal: AbortSignal): Promise { + console.log(`Unfurling ${url}...`); + try { + const result = await unfurl(url, { + fetch: (url) => fetch(url, { signal }), + }); + + return { + type: result.oEmbed?.type || 'link', + url: result.canonical_url || url, + title: result.oEmbed?.title || result.title || '', + description: result.open_graph.description || result.description || '', + author_name: result.oEmbed?.author_name || '', + author_url: result.oEmbed?.author_url || '', + provider_name: result.oEmbed?.provider_name || '', + provider_url: result.oEmbed?.provider_url || '', + // @ts-expect-error `html` does in fact exist on oEmbed. + html: sanitizeHtml(result.oEmbed?.html || '', { + allowedTags: ['iframe'], + allowedAttributes: { + iframe: ['width', 'height', 'src', 'frameborder', 'allowfullscreen'], + }, + }), + width: result.oEmbed?.width || 0, + height: result.oEmbed?.height || 0, + image: result.oEmbed?.thumbnails?.[0].url || result.open_graph.images?.[0].url || null, + embed_url: '', + blurhash: null, + }; + } catch (_e) { + return null; + } +} + +/** TTL cache for preview cards. */ +const previewCardCache = new TTLCache>({ + ttl: Time.hours(12), + max: 500, +}); + +/** Unfurl card from cache if available, otherwise fetch it. */ +function unfurlCardCached(url: string, timeout = Time.seconds(1)): Promise { + const cached = previewCardCache.get(url); + if (cached !== undefined) { + return cached; + } else { + const card = unfurlCard(url, AbortSignal.timeout(timeout)); + previewCardCache.set(url, card); + return card; + } +} + +export { type PreviewCard, unfurlCardCached }; diff --git a/src/views.ts b/src/views.ts index 2765c6d..3049710 100644 --- a/src/views.ts +++ b/src/views.ts @@ -2,7 +2,7 @@ import { AppContext } from '@/app.ts'; import { type Filter } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; import { getAuthor } from '@/queries.ts'; -import { toAccount } from '@/transformers/nostr-to-mastoapi.ts'; +import { renderAccount } from '@/views/mastodon/accounts.ts'; import { paginated } from '@/utils/web.ts'; /** Render account objects for the author of each event. */ @@ -17,7 +17,7 @@ async function renderEventAccounts(c: AppContext, filters: Filter[]) { const accounts = await Promise.all([...pubkeys].map(async (pubkey) => { const author = await getAuthor(pubkey); if (author) { - return toAccount(author); + return renderAccount(author); } })); diff --git a/src/transformers/nostr-to-activitypub.ts b/src/views/activitypub/actor.ts similarity index 91% rename from src/transformers/nostr-to-activitypub.ts rename to src/views/activitypub/actor.ts index ca90625..1f2f679 100644 --- a/src/transformers/nostr-to-activitypub.ts +++ b/src/views/activitypub/actor.ts @@ -6,7 +6,7 @@ import type { Event } from '@/deps.ts'; import type { Actor } from '@/schemas/activitypub.ts'; /** Nostr metadata event to ActivityPub actor. */ -async function toActor(event: Event<0>, username: string): Promise { +async function renderActor(event: Event<0>, username: string): Promise { const content = jsonMetaContentSchema.parse(event.content); return { @@ -44,4 +44,4 @@ async function toActor(event: Event<0>, username: string): Promise, opts: ToAccountOpts = {}) { + const { withSource = false } = opts; + const { pubkey } = event; + + const { + name, + nip05, + picture = Conf.local('/images/avi.png'), + banner = Conf.local('/images/banner.png'), + about, + } = jsonMetaContentSchema.parse(event.content); + + const npub = nip19.npubEncode(pubkey); + + const [user, parsed05, followersCount, followingCount, statusesCount] = await Promise.all([ + findUser({ pubkey }), + parseAndVerifyNip05(nip05, pubkey), + eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]), + getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length), + eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]), + ]); + + return { + id: pubkey, + acct: parsed05?.handle || npub, + avatar: picture, + avatar_static: picture, + bot: false, + created_at: event ? nostrDate(event.created_at).toISOString() : new Date().toISOString(), + discoverable: true, + display_name: name, + emojis: renderEmojis(event), + fields: [], + follow_requests_count: 0, + followers_count: followersCount, + following_count: followingCount, + fqn: parsed05?.handle || npub, + header: banner, + header_static: banner, + last_status_at: null, + locked: false, + note: lodash.escape(about), + roles: [], + source: withSource + ? { + fields: [], + language: '', + note: about || '', + privacy: 'public', + sensitive: false, + follow_requests_count: 0, + } + : undefined, + statuses_count: statusesCount, + url: Conf.local(`/users/${pubkey}`), + username: parsed05?.nickname || npub.substring(0, 8), + pleroma: { + is_admin: user?.admin || false, + is_moderator: user?.admin || false, + }, + }; +} + +function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) { + const event: UnsignedEvent<0> = { + kind: 0, + pubkey, + content: '', + tags: [], + created_at: nostrNow(), + }; + + return renderAccount(event, opts); +} + +async function parseAndVerifyNip05(nip05: string | undefined, pubkey: string): Promise { + if (nip05 && await verifyNip05Cached(nip05, pubkey)) { + return parseNip05(nip05); + } +} + +export { accountFromPubkey, renderAccount }; diff --git a/src/views/attachment.ts b/src/views/mastodon/attachments.ts similarity index 100% rename from src/views/attachment.ts rename to src/views/mastodon/attachments.ts diff --git a/src/views/mastodon/emojis.ts b/src/views/mastodon/emojis.ts new file mode 100644 index 0000000..0ba2895 --- /dev/null +++ b/src/views/mastodon/emojis.ts @@ -0,0 +1,19 @@ +import { UnsignedEvent } from '@/deps.ts'; +import { EmojiTag, emojiTagSchema } from '@/schemas/nostr.ts'; +import { filteredArray } from '@/schema.ts'; + +function renderEmoji([_, shortcode, url]: EmojiTag) { + return { + shortcode, + static_url: url, + url, + }; +} + +function renderEmojis({ tags }: UnsignedEvent) { + return filteredArray(emojiTagSchema) + .parse(tags) + .map(renderEmoji); +} + +export { renderEmojis }; diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts new file mode 100644 index 0000000..59e5665 --- /dev/null +++ b/src/views/mastodon/notifications.ts @@ -0,0 +1,26 @@ +import { type Event } from '@/deps.ts'; +import { nostrDate } from '@/utils.ts'; +import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; + +function renderNotification(event: Event, viewerPubkey?: string) { + switch (event.kind) { + case 1: + return renderNotificationMention(event as Event<1>, viewerPubkey); + } +} + +async function renderNotificationMention(event: Event<1>, viewerPubkey?: string) { + const status = await renderStatus(event, viewerPubkey); + if (!status) return; + + return { + id: event.id, + type: 'mention', + created_at: nostrDate(event.created_at).toISOString(), + account: status.account, + status: status, + }; +} + +export { accountFromPubkey, renderNotification }; diff --git a/src/views/mastodon/relationships.ts b/src/views/mastodon/relationships.ts new file mode 100644 index 0000000..91d33ea --- /dev/null +++ b/src/views/mastodon/relationships.ts @@ -0,0 +1,26 @@ +import { getFollows } from '@/queries.ts'; +import { isFollowing } from '@/utils.ts'; + +async function renderRelationship(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 { renderRelationship }; diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts new file mode 100644 index 0000000..131dcd6 --- /dev/null +++ b/src/views/mastodon/statuses.ts @@ -0,0 +1,126 @@ +import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; + +import { Conf } from '@/config.ts'; +import * as eventsDB from '@/db/events.ts'; +import { type Event, findReplyTag, nip19 } from '@/deps.ts'; +import { getMediaLinks, parseNoteContent } from '@/note.ts'; +import { getAuthor } from '@/queries.ts'; +import { jsonMediaDataSchema } from '@/schemas/nostr.ts'; +import { nostrDate } from '@/utils.ts'; +import { unfurlCardCached } from '@/utils/unfurl.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; +import { renderEmojis } from '@/views/mastodon/emojis.ts'; + +async function renderStatus(event: Event<1>, viewerPubkey?: string) { + const profile = await getAuthor(event.pubkey); + const account = profile ? await renderAccount(profile) : await accountFromPubkey(event.pubkey); + + const replyTag = findReplyTag(event); + + const mentionedPubkeys = [ + ...new Set( + event.tags + .filter((tag) => tag[0] === 'p') + .map((tag) => tag[1]), + ), + ]; + + const { html, links, firstUrl } = parseNoteContent(event.content); + + const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise + .all([ + Promise.all(mentionedPubkeys.map(toMention)), + firstUrl ? unfurlCardCached(firstUrl) : null, + eventsDB.countFilters([{ kinds: [1], '#e': [event.id] }]), + eventsDB.countFilters([{ kinds: [6], '#e': [event.id] }]), + eventsDB.countFilters([{ kinds: [7], '#e': [event.id] }]), + viewerPubkey + ? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 }) + : [], + viewerPubkey + ? eventsDB.getFilters([{ kinds: [7], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 }) + : [], + ]); + + const content = buildInlineRecipients(mentions) + html; + + const cw = event.tags.find(isCWTag); + const subject = event.tags.find((tag) => tag[0] === 'subject'); + + const mediaLinks = getMediaLinks(links); + + const mediaTags: DittoAttachment[] = event.tags + .filter((tag) => tag[0] === 'media') + .map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) })); + + const media = [...mediaLinks, ...mediaTags]; + + return { + id: event.id, + account, + card, + content, + created_at: nostrDate(event.created_at).toISOString(), + in_reply_to_id: replyTag ? replyTag[1] : null, + in_reply_to_account_id: null, + sensitive: !!cw, + spoiler_text: (cw ? cw[1] : subject?.[1]) || '', + visibility: 'public', + language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null, + replies_count: repliesCount, + reblogs_count: reblogsCount, + favourites_count: favouritesCount, + favourited: reactionEvent?.content === '+', + reblogged: Boolean(repostEvent), + muted: false, + bookmarked: false, + reblog: null, + application: null, + media_attachments: media.map(renderAttachment), + mentions, + tags: [], + emojis: renderEmojis(event), + poll: null, + uri: Conf.local(`/posts/${event.id}`), + url: Conf.local(`/posts/${event.id}`), + }; +} + +async function toMention(pubkey: string) { + const profile = await getAuthor(pubkey); + const account = profile ? await renderAccount(profile) : undefined; + + if (account) { + return { + id: account.id, + acct: account.acct, + username: account.username, + url: account.url, + }; + } else { + const npub = nip19.npubEncode(pubkey); + return { + id: pubkey, + acct: npub, + username: npub.substring(0, 8), + url: Conf.local(`/users/${pubkey}`), + }; + } +} + +type Mention = Awaited>; + +function buildInlineRecipients(mentions: Mention[]): string { + if (!mentions.length) return ''; + + const elements = mentions.reduce((acc, { url, username }) => { + const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; + acc.push(`@${name}`); + return acc; + }, []); + + return `${elements.join(' ')} `; +} + +export { renderStatus };