diff --git a/src/schemas/activitypub.ts b/src/schemas/activitypub.ts new file mode 100644 index 0000000..04fac39 --- /dev/null +++ b/src/schemas/activitypub.ts @@ -0,0 +1,323 @@ +import { z } from '@/deps.ts'; + +const apId = z.string().url(); +const recipients = z.array(z.string()).catch([]); +const published = () => z.string().datetime().catch(new Date().toISOString()); + +/** Validates individual items in an array, dropping any that aren't valid. */ +function filteredArray(schema: T) { + return z.any().array() + .transform((arr) => ( + arr.map((item) => { + const parsed = schema.safeParse(item); + return parsed.success ? parsed.data : undefined; + }).filter((item): item is z.infer => Boolean(item)) + )); +} + +const imageSchema = z.object({ + type: z.literal('Image').catch('Image'), + url: z.string().url(), +}); + +const attachmentSchema = z.object({ + type: z.literal('Document').catch('Document'), + mediaType: z.string().optional().catch(undefined), + url: z.string().url(), +}); + +const mentionSchema = z.object({ + type: z.literal('Mention'), + href: z.string().url(), + name: z.string().optional().catch(undefined), +}); + +const hashtagSchema = z.object({ + type: z.literal('Hashtag'), + href: z.string().url(), + name: z.string(), +}); + +const emojiSchema = z.object({ + type: z.literal('Emoji'), + icon: imageSchema, + name: z.string(), +}); + +const tagSchema = z.discriminatedUnion('type', [ + mentionSchema, + hashtagSchema, + emojiSchema, +]); + +const propertyValueSchema = z.object({ + type: z.literal('PropertyValue'), + name: z.string(), + value: z.string(), + verified_at: z.string().nullish(), +}); + +/** https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-fffd.md */ +const proxySchema = z.object({ + protocol: z.string().url(), + proxied: z.string(), + authoritative: z.boolean().optional().catch(undefined), +}); + +const personSchema = z.object({ + type: z.literal('Person'), + id: apId, + icon: imageSchema.optional().catch(undefined), + image: imageSchema.optional().catch(undefined), + name: z.string().catch(''), + preferredUsername: z.string(), + inbox: apId, + followers: apId.optional().catch(undefined), + following: apId.optional().catch(undefined), + outbox: apId.optional().catch(undefined), + summary: z.string().catch(''), + attachment: filteredArray(propertyValueSchema).catch([]), + tag: filteredArray(emojiSchema).catch([]), + endpoints: z.object({ + sharedInbox: apId.optional(), + }).optional().catch({}), + publicKey: z.object({ + id: apId, + owner: apId, + publicKeyPem: z.string(), + }).optional().catch(undefined), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const applicationSchema = personSchema.merge(z.object({ type: z.literal('Application') })); +const groupSchema = personSchema.merge(z.object({ type: z.literal('Group') })); +const organizationSchema = personSchema.merge(z.object({ type: z.literal('Organization') })); +const serviceSchema = personSchema.merge(z.object({ type: z.literal('Service') })); + +const actorSchema = z.discriminatedUnion('type', [ + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, +]); + +const noteSchema = z.object({ + type: z.literal('Note'), + id: apId, + to: recipients, + cc: recipients, + content: z.string(), + attachment: z.array(attachmentSchema).optional().catch(undefined), + tag: filteredArray(tagSchema).catch([]), + inReplyTo: apId.optional().catch(undefined), + attributedTo: apId, + published: published(), + sensitive: z.boolean().optional().catch(undefined), + summary: z.string().nullish().catch(undefined), + quoteUrl: apId.optional().catch(undefined), + source: z.object({ + content: z.string(), + mediaType: z.literal('text/markdown'), + }).optional().catch(undefined), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const flexibleNoteSchema = noteSchema.extend({ + quoteURL: apId.optional().catch(undefined), + quoteUri: apId.optional().catch(undefined), + _misskey_quote: apId.optional().catch(undefined), +}).transform((note) => { + const { quoteUrl, quoteUri, quoteURL, _misskey_quote, ...rest } = note; + return { + quoteUrl: quoteUrl || quoteUri || quoteURL || _misskey_quote, + ...rest, + }; +}); + +// https://github.com/colinhacks/zod/discussions/2100#discussioncomment-5109781 +const objectSchema = z.union([ + flexibleNoteSchema, + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, +]).pipe( + z.discriminatedUnion('type', [ + noteSchema, + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, + ]), +); + +const createNoteSchema = z.object({ + type: z.literal('Create'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: noteSchema, + published: published(), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const announceNoteSchema = z.object({ + type: z.literal('Announce'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: apId.or(noteSchema), + published: published(), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const followSchema = z.object({ + type: z.literal('Follow'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: apId, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const acceptSchema = z.object({ + type: z.literal('Accept'), + id: apId, + actor: apId, + to: recipients, + cc: recipients, + object: apId.or(followSchema), +}); + +const likeSchema = z.object({ + type: z.literal('Like'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const emojiReactSchema = z.object({ + type: z.literal('EmojiReact'), + id: apId, + actor: apId, + object: apId, + content: z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)), + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const deleteSchema = z.object({ + type: z.literal('Delete'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const updateActorSchema = z.object({ + type: z.literal('Update'), + id: apId, + actor: apId, + to: recipients, + cc: recipients, + object: actorSchema, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +/** + * A custom Zap activity type we made up, based on: + * https://github.com/nostr-protocol/nips/blob/master/57.md + */ +const zapSchema = z.object({ + type: z.literal('Zap'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const activitySchema = z.discriminatedUnion('type', [ + followSchema, + acceptSchema, + createNoteSchema, + announceNoteSchema, + updateActorSchema, + likeSchema, + emojiReactSchema, + deleteSchema, + zapSchema, +]).refine((activity) => { + const ids: string[] = [activity.id]; + + if (activity.type === 'Create') { + ids.push( + activity.object.id, + activity.object.attributedTo, + ); + } + + if (activity.type === 'Update') { + ids.push(activity.object.id); + } + + const { origin: actorOrigin } = new URL(activity.actor); + + // Object containment + return ids.every((id) => { + const { origin: idOrigin } = new URL(id); + return idOrigin === actorOrigin; + }); +}); + +type Activity = z.infer; +type CreateNote = z.infer; +type Announce = z.infer; +type Update = z.infer; +type Object = z.infer; +type Follow = z.infer; +type Accept = z.infer; +type Actor = z.infer; +type Note = z.infer; +type Mention = z.infer; +type Hashtag = z.infer; +type Emoji = z.infer; +type Like = z.infer; +type EmojiReact = z.infer; +type Delete = z.infer; +type Zap = z.infer; +type Proxy = z.infer; + +export { acceptSchema, activitySchema, actorSchema, emojiSchema, followSchema, imageSchema, noteSchema, objectSchema }; +export type { + Accept, + Activity, + Actor, + Announce, + CreateNote, + Delete, + Emoji, + EmojiReact, + Follow, + Hashtag, + Like, + Mention, + Note, + Object, + Proxy, + Update, + Zap, +}; diff --git a/src/transmogrifier/nostr-to-activitypub.ts b/src/transmogrifier/nostr-to-activitypub.ts new file mode 100644 index 0000000..f7c402a --- /dev/null +++ b/src/transmogrifier/nostr-to-activitypub.ts @@ -0,0 +1,41 @@ +import { Conf } from '@/config.ts'; +import { parseMetaContent } from '@/schema.ts'; + +import type { Event } from '@/event.ts'; +import type { Actor } from '@/schemas/activitypub.ts'; + +/** Nostr metadata event to ActivityPub actor. */ +async function toActor(event: Event<0>, username: string): Promise { + const content = parseMetaContent(event); + + return { + type: 'Person', + id: Conf.local(`/users/${username}`), + name: content?.name || '', + preferredUsername: username, + inbox: Conf.local(`/users/${username}/inbox`), + followers: Conf.local(`/users/${username}/followers`), + following: Conf.local(`/users/${username}/following`), + outbox: Conf.local(`/users/${username}/outbox`), + icon: content.picture + ? { + type: 'Image', + url: content.picture, + } + : undefined, + image: content.banner + ? { + type: 'Image', + url: content.banner, + } + : undefined, + summary: content.about ?? '', + attachment: [], + tag: [], + endpoints: { + sharedInbox: Conf.local('/inbox'), + }, + }; +} + +export { toActor }; \ No newline at end of file