ditto/src/schemas/activitypub.ts
2024-04-23 15:03:20 -05:00

323 lines
8.1 KiB
TypeScript

import { z } from 'zod';
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<T extends z.ZodTypeAny>(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<T> => 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<typeof activitySchema>;
type CreateNote = z.infer<typeof createNoteSchema>;
type Announce = z.infer<typeof announceNoteSchema>;
type Update = z.infer<typeof updateActorSchema>;
type Object = z.infer<typeof objectSchema>;
type Follow = z.infer<typeof followSchema>;
type Accept = z.infer<typeof acceptSchema>;
type Actor = z.infer<typeof actorSchema>;
type Note = z.infer<typeof noteSchema>;
type Mention = z.infer<typeof mentionSchema>;
type Hashtag = z.infer<typeof hashtagSchema>;
type Emoji = z.infer<typeof emojiSchema>;
type Like = z.infer<typeof likeSchema>;
type EmojiReact = z.infer<typeof emojiReactSchema>;
type Delete = z.infer<typeof deleteSchema>;
type Zap = z.infer<typeof zapSchema>;
type Proxy = z.infer<typeof proxySchema>;
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,
};