From 4a470f5f3cd0777d5a04bba8a708fd723e6aa26f Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 25 Aug 2024 15:47:03 +0200 Subject: [PATCH] refactor(federation): :alien: Update all schemas to Working Draft 4 --- federation/http.test.ts | 34 +- federation/schemas.ts | 32 +- federation/schemas/base.ts | 314 +++++++++++------- federation/schemas/content_format.ts | 123 ++++++- federation/schemas/extensions.ts | 10 +- .../schemas/extensions/custom_emojis.ts | 32 +- federation/schemas/extensions/likes.ts | 40 +++ federation/schemas/extensions/polls.ts | 87 +---- federation/schemas/extensions/reactions.ts | 21 +- federation/schemas/extensions/share.ts | 21 ++ federation/schemas/extensions/vanity.ts | 88 ++--- federation/schemas/regex.ts | 36 +- federation/validator.ts | 114 +------ 13 files changed, 522 insertions(+), 430 deletions(-) create mode 100644 federation/schemas/extensions/likes.ts create mode 100644 federation/schemas/extensions/share.ts diff --git a/federation/http.test.ts b/federation/http.test.ts index eedca9e..1e7a9c7 100644 --- a/federation/http.test.ts +++ b/federation/http.test.ts @@ -9,40 +9,42 @@ const validUser = { uri: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a", bio: { "text/html": { - content: "

Hey

\n", + content: "

Hey

", + remote: false, }, }, - created_at: "2024-04-07T11:48:29.623Z", - dislikes: - "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/dislikes", - featured: - "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/featured", - likes: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/likes", - followers: - "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers", - following: - "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/following", inbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/inbox", - outbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/outbox", indexable: false, + created_at: "2024-04-07T11:48:29.623Z", + collections: { + featured: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/featured", + followers: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers", + following: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/following", + outbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/outbox", + }, username: "jessew", display_name: "Jesse Wierzbinski", fields: [ { - key: { "text/html": { content: "

Identity

\n" } }, + key: { "text/html": { content: "

Identity

", remote: false } }, value: { "text/html": { content: - '

https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA

\n', + '

https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA

', + remote: false, }, }, }, ], public_key: { actor: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a", - public_key: "XXXXXXXX", + key: "XXXXXXXX", + algorithm: "ed25519", }, - extensions: { "org.lysand:custom_emojis": { emojis: [] } }, + extensions: { "pub.versia:custom_emojis": { emojis: [] } }, }; describe("RequestParserHandler", () => { diff --git a/federation/schemas.ts b/federation/schemas.ts index fca54dd..2159ac0 100644 --- a/federation/schemas.ts +++ b/federation/schemas.ts @@ -5,23 +5,17 @@ import type { z } from "zod"; import type { - ActionSchema, - ActorPublicKeyDataSchema, - DislikeSchema, + DeleteSchema, EntitySchema, - ExtensionSchema, FollowAcceptSchema, FollowRejectSchema, FollowSchema, - LikeSchema, + GroupSchema, + InstanceMetadataSchema, NoteSchema, - PatchSchema, - PublicationSchema, - ReportSchema, - ServerMetadataSchema, - UndoSchema, + PublicKeyDataSchema, + UnfollowSchema, UserSchema, - VisibilitySchema, } from "./schemas/base"; import type { ContentFormatSchema } from "./schemas/content_format"; import type { ExtensionPropertySchema } from "./schemas/extensions"; @@ -34,23 +28,17 @@ type AnyZod = z.ZodType; type InferType = z.infer; export type Note = InferType; -export type Patch = InferType; -export type ActorPublicKeyData = InferType; +export type ActorPublicKeyData = InferType; export type ExtensionProperty = InferType; export type VanityExtension = InferType; export type User = InferType; -export type Action = InferType; -export type Like = InferType; -export type Undo = InferType; -export type Dislike = InferType; export type Follow = InferType; export type FollowAccept = InferType; export type FollowReject = InferType; -export type Extension = InferType; -export type Report = InferType; -export type ServerMetadata = InferType; export type ContentFormat = InferType; export type CustomEmojiExtension = InferType; -export type Visibility = InferType; -export type Publication = InferType; export type Entity = InferType; +export type Delete = InferType; +export type Group = InferType; +export type InstanceMetadata = InferType; +export type Unfollow = InferType; diff --git a/federation/schemas/base.ts b/federation/schemas/base.ts index 262698e..445788c 100644 --- a/federation/schemas/base.ts +++ b/federation/schemas/base.ts @@ -1,51 +1,103 @@ import { z } from "zod"; -import { ContentFormatSchema } from "./content_format"; +import { + ContentFormatSchema, + ImageOnlyContentFormatSchema, + TextOnlyContentFormatSchema, +} from "./content_format"; import { ExtensionPropertySchema } from "./extensions"; import { VanityExtensionSchema } from "./extensions/vanity"; -import { extensionTypeRegex } from "./regex"; - -export const EntitySchema = z.object({ - id: z.string().uuid(), - created_at: z.string(), - uri: z.string().url(), - type: z.string(), - extensions: ExtensionPropertySchema.optional().nullable().nullable(), -}); - -export const VisibilitySchema = z.enum([ - "public", - "unlisted", - "private", - "direct", -]); +import { extensionRegex, isISOString, semverRegex } from "./regex"; + +export const EntitySchema = z + .object({ + id: z.string().max(512), + created_at: z + .string() + .refine((v) => isISOString(v), "must be a valid ISO8601 datetime"), + uri: z.string().url(), + type: z.string(), + extensions: ExtensionPropertySchema.optional().nullable(), + }) + .strict(); -export const PublicationSchema = EntitySchema.extend({ - type: z.enum(["Note", "Patch"]), - author: z.string().url(), - content: ContentFormatSchema.optional().nullable(), +export const NoteSchema = EntitySchema.extend({ + type: z.literal("Note"), attachments: z.array(ContentFormatSchema).optional().nullable(), - replies_to: z.string().url().optional().nullable(), - quotes: z.string().url().optional().nullable(), + author: z.string().url(), + category: z + .enum([ + "microblog", + "forum", + "blog", + "image", + "video", + "audio", + "messaging", + ]) + .optional() + .nullable(), + content: TextOnlyContentFormatSchema.optional().nullable(), + device: z + .object({ + name: z.string(), + version: z.string().optional().nullable(), + url: z.string().url().optional().nullable(), + }) + .strict() + .optional() + .nullable(), + group: z + .string() + .url() + .or(z.enum(["public", "followers"])) + .optional() + .nullable(), + is_sensitive: z.boolean().optional().nullable(), mentions: z.array(z.string().url()).optional().nullable(), + previews: z + .array( + z + .object({ + link: z.string().url(), + title: z.string(), + description: z.string().optional().nullable(), + image: z.string().url().optional().nullable(), + icon: z.string().url().optional().nullable(), + }) + .strict(), + ) + .optional() + .nullable(), + quotes: z.string().url().optional().nullable(), + replies_to: z.string().url().optional().nullable(), subject: z.string().optional().nullable(), - is_sensitive: z.boolean().optional().nullable(), - visibility: VisibilitySchema, extensions: ExtensionPropertySchema.extend({ - "org.lysand:reactions": z + "pub.versia:reactions": z .object({ - reactions: z.string(), + reactions: z.string().url(), }) + .strict() .optional() .nullable(), - "org.lysand:polls": z + "pub.versia:polls": z .object({ - poll: z.object({ - options: z.array(ContentFormatSchema), - votes: z.array(z.number().int().nonnegative()), - multiple_choice: z.boolean().optional().nullable(), - expires_at: z.string(), - }), + options: z.array(TextOnlyContentFormatSchema), + votes: z.array( + z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1), + ), + multiple_choice: z.boolean(), + expires_at: z + .string() + .refine( + (v) => isISOString(v), + "must be a valid ISO8601 datetime", + ), }) + .strict() .optional() .nullable(), }) @@ -53,127 +105,141 @@ export const PublicationSchema = EntitySchema.extend({ .nullable(), }); -export const NoteSchema = PublicationSchema.extend({ - type: z.literal("Note"), -}); - -export const PatchSchema = PublicationSchema.extend({ - type: z.literal("Patch"), - patched_id: z.string().uuid(), - patched_at: z.string(), -}); - -export const ActorPublicKeyDataSchema = z.object({ - public_key: z.string(), - actor: z.string().url(), -}); +export const PublicKeyDataSchema = z + .object({ + key: z.string().min(1), + actor: z.string().url(), + algorithm: z.literal("ed25519"), + }) + .strict(); export const UserSchema = EntitySchema.extend({ type: z.literal("User"), + avatar: ImageOnlyContentFormatSchema.optional().nullable(), + bio: TextOnlyContentFormatSchema.optional().nullable(), display_name: z.string().optional().nullable(), - username: z.string(), - avatar: ContentFormatSchema.optional().nullable(), - header: ContentFormatSchema.optional().nullable(), - indexable: z.boolean(), - public_key: ActorPublicKeyDataSchema, - bio: ContentFormatSchema.optional().nullable(), fields: z .array( - z.object({ - key: ContentFormatSchema, - value: ContentFormatSchema, - }), + z + .object({ + key: TextOnlyContentFormatSchema, + value: TextOnlyContentFormatSchema, + }) + .strict(), ) .optional() .nullable(), - featured: z.string().url(), - followers: z.string().url(), - following: z.string().url(), - likes: z.string().url(), - dislikes: z.string().url(), + username: z + .string() + .min(1) + .regex( + /^[a-z0-9_-]+$/, + "must be lowercase, alphanumeric, and may contain _ or -", + ), + header: ImageOnlyContentFormatSchema.optional().nullable(), + public_key: PublicKeyDataSchema, + manually_approves_followers: z.boolean().optional().nullable(), + indexable: z.boolean().optional().nullable(), inbox: z.string().url(), - outbox: z.string().url(), + collections: z + .object({ + featured: z.string().url(), + followers: z.string().url(), + following: z.string().url(), + outbox: z.string().url(), + "pub.versia:likes/Likes": z.string().url().optional().nullable(), + "pub.versia:likes/Dislikes": z.string().url().optional().nullable(), + }) + .catchall(z.string().url()), extensions: ExtensionPropertySchema.extend({ - "org.lysand:vanity": VanityExtensionSchema.optional().nullable(), + "pub.versia:vanity": VanityExtensionSchema.optional().nullable(), }) .optional() .nullable(), }); -export const ActionSchema = EntitySchema.extend({ - type: z.union([ - z.literal("Like"), - z.literal("Dislike"), - z.literal("Follow"), - z.literal("FollowAccept"), - z.literal("FollowReject"), - z.literal("Announce"), - z.literal("Undo"), - ]), +export const DeleteSchema = EntitySchema.extend({ + uri: z.null().optional(), + type: z.literal("Delete"), author: z.string().url(), + deleted_type: z.string(), + target: z.string().url(), }); -export const LikeSchema = ActionSchema.extend({ - type: z.literal("Like"), - object: z.string().url(), -}); - -export const UndoSchema = ActionSchema.extend({ - type: z.literal("Undo"), - object: z.string().url(), -}); - -export const DislikeSchema = ActionSchema.extend({ - type: z.literal("Dislike"), - object: z.string().url(), -}); - -export const FollowSchema = ActionSchema.extend({ +export const FollowSchema = EntitySchema.extend({ type: z.literal("Follow"), + uri: z.null().optional(), + author: z.string().url(), followee: z.string().url(), }); -export const FollowAcceptSchema = ActionSchema.extend({ +export const FollowAcceptSchema = EntitySchema.extend({ type: z.literal("FollowAccept"), + uri: z.null().optional(), + author: z.string().url(), follower: z.string().url(), }); -export const FollowRejectSchema = ActionSchema.extend({ +export const FollowRejectSchema = EntitySchema.extend({ type: z.literal("FollowReject"), + uri: z.null().optional(), + author: z.string().url(), follower: z.string().url(), }); -export const ExtensionSchema = EntitySchema.extend({ - type: z.literal("Extension"), - extension_type: z - .string() - .regex( - extensionTypeRegex, - "extension_type must be in the format 'namespaced_url:extension_name/ExtensionType', e.g. 'org.lysand:reactions/Reaction'. Notably, only the type can have uppercase letters.", - ), -}); - -export const ReportSchema = ExtensionSchema.extend({ - extension_type: z.literal("org.lysand:reports/Report"), - objects: z.array(z.string().url()), - reason: z.string(), - comment: z.string().optional().nullable(), +export const UnfollowSchema = EntitySchema.extend({ + type: z.literal("Unfollow"), + uri: z.null().optional(), + author: z.string().url(), + followee: z.string().url(), }); -export const ServerMetadataSchema = EntitySchema.omit({ - created_at: true, - id: true, - uri: true, -}).extend({ - type: z.literal("ServerMetadata"), - name: z.string(), - version: z.string(), - description: z.string().optional().nullable(), - website: z.string().optional().nullable(), - moderators: z.array(z.string()).optional().nullable(), - admins: z.array(z.string()).optional().nullable(), - logo: ContentFormatSchema.optional().nullable(), - banner: ContentFormatSchema.optional().nullable(), - supported_extensions: z.array(z.string()), - extensions: z.record(z.string(), z.any()).optional().nullable(), +export const GroupSchema = EntitySchema.extend({ + type: z.literal("Group"), + name: TextOnlyContentFormatSchema.optional().nullable(), + description: TextOnlyContentFormatSchema.optional().nullable(), + members: z.string().url(), + notes: z.string().url().optional().nullable(), +}); + +export const InstanceMetadataSchema = EntitySchema.extend({ + type: z.literal("InstanceMetadata"), + id: z.null().optional(), + uri: z.null().optional(), + name: z.string().min(1), + software: z + .object({ + name: z.string().min(1), + version: z.string().min(1), + }) + .strict(), + compatibility: z + .object({ + versions: z.array( + z.string().regex(semverRegex, "must be a valid SemVer version"), + ), + extensions: z.array( + z + .string() + .min(1) + .regex( + extensionRegex, + "must be in the format 'namespaced_url:extension_name', e.g. 'pub.versia:reactions'", + ), + ), + }) + .strict(), + description: TextOnlyContentFormatSchema.optional().nullable(), + host: z.string(), + shared_inbox: z.string().url().optional().nullable(), + public_key: z + .object({ + key: z.string().min(1), + algorithm: z.literal("ed25519"), + }) + .strict(), + moderators: z.string().url().optional().nullable(), + admins: z.string().url().optional().nullable(), + logo: ImageOnlyContentFormatSchema.optional().nullable(), + banner: ImageOnlyContentFormatSchema.optional().nullable(), }); diff --git a/federation/schemas/content_format.ts b/federation/schemas/content_format.ts index 3d2a5c6..9d9508d 100644 --- a/federation/schemas/content_format.ts +++ b/federation/schemas/content_format.ts @@ -1,17 +1,114 @@ import { types } from "mime-types"; import { z } from "zod"; -export const ContentFormatSchema = z.record( - z.enum(Object.values(types) as [string, ...string[]]), - z.object({ - content: z.string(), - description: z.string().optional().nullable(), - size: z.number().int().nonnegative().optional().nullable(), - hash: z.record(z.string(), z.string()).optional().nullable(), - blurhash: z.string().optional().nullable(), - fps: z.number().int().nonnegative().optional().nullable(), - width: z.number().int().nonnegative().optional().nullable(), - height: z.number().int().nonnegative().optional().nullable(), - duration: z.number().nonnegative().optional().nullable(), - }), +const hashes = { + sha256: 64, + sha512: 128, + "sha3-256": 64, + "sha3-512": 128, + "blake2b-256": 64, + "blake2b-512": 128, + "blake3-256": 64, + "blake3-512": 128, + md5: 32, + sha1: 40, + sha224: 56, + sha384: 96, + "sha3-224": 56, + "sha3-384": 96, + "blake2s-256": 64, + "blake2s-512": 128, + "blake3-224": 56, + "blake3-384": 96, +}; + +const contentFormatFromAllowedMimes = (allowedMimes: [string, ...string[]]) => + z.record( + z.enum(allowedMimes), + z + .object({ + content: z.string(), + remote: z.boolean(), + description: z.string().optional().nullable(), + size: z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1) + .optional() + .nullable(), + hash: z + .object( + Object.fromEntries( + Object.entries(hashes).map(([k, v]) => [ + k, + z.string().length(v).optional().nullable(), + ]), + ), + ) + .strict() + .optional() + .nullable(), + thumbhash: z.string().optional().nullable(), + fps: z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1) + .optional() + .nullable(), + width: z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1) + .optional() + .nullable(), + height: z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1) + .optional() + .nullable(), + duration: z + .number() + .nonnegative() + .max(2 ** 16 - 1) + .optional() + .nullable(), + }) + .strict() + .refine( + (v) => + v.remote + ? z.string().url().safeParse(v.content).success + : true, + "if remote is true, content must be a valid URL", + ), + ); + +export const ContentFormatSchema = contentFormatFromAllowedMimes( + Object.values(types) as [string, ...string[]], +); + +export const ImageOnlyContentFormatSchema = contentFormatFromAllowedMimes( + Object.values(types).filter((v) => v.startsWith("image/")) as [ + string, + ...string[], + ], +); + +export const TextOnlyContentFormatSchema = contentFormatFromAllowedMimes( + Object.values(types).filter((v) => v.startsWith("text/")) as [ + string, + ...string[], + ], +); + +export const AudioOnlyContentFormatSchema = contentFormatFromAllowedMimes( + Object.values(types).filter((v) => v.startsWith("audio/")) as [ + string, + ...string[], + ], ); diff --git a/federation/schemas/extensions.ts b/federation/schemas/extensions.ts index 7078bde..2077fed 100644 --- a/federation/schemas/extensions.ts +++ b/federation/schemas/extensions.ts @@ -1,7 +1,9 @@ import { z } from "zod"; import { CustomEmojiExtensionSchema } from "./extensions/custom_emojis"; -export const ExtensionPropertySchema = z.object({ - "org.lysand:custom_emojis": - CustomEmojiExtensionSchema.optional().nullable(), -}); +export const ExtensionPropertySchema = z + .object({ + "pub.versia:custom_emojis": + CustomEmojiExtensionSchema.optional().nullable(), + }) + .catchall(z.any()); diff --git a/federation/schemas/extensions/custom_emojis.ts b/federation/schemas/extensions/custom_emojis.ts index 8573f54..573c44b 100644 --- a/federation/schemas/extensions/custom_emojis.ts +++ b/federation/schemas/extensions/custom_emojis.ts @@ -5,7 +5,7 @@ * @see https://versia.pub/extensions/custom-emojis */ import { z } from "zod"; -import { ContentFormatSchema } from "../content_format"; +import { ImageOnlyContentFormatSchema } from "../content_format"; import { emojiRegex } from "../regex"; /** @@ -15,14 +15,14 @@ import { emojiRegex } from "../regex"; * { * // ... * "extensions": { - * "org.lysand:custom_emojis": { + * "pub.versia:custom_emojis": { * "emojis": [ * { - * "name": "happy_face", + * "name": ":happy_face:", * "url": { * "image/png": { * "content": "https://cdn.example.com/emojis/happy_face.png", - * "content_type": "image/png" + * "remote": true * } * } * }, @@ -35,16 +35,18 @@ import { emojiRegex } from "../regex"; */ export const CustomEmojiExtensionSchema = z.object({ emojis: z.array( - z.object({ - name: z - .string() - .min(1) - .max(256) - .regex( - emojiRegex, - "Emoji name must be alphanumeric, underscores, or dashes.", - ), - url: ContentFormatSchema, - }), + z + .object({ + name: z + .string() + .min(1) + .max(256) + .regex( + emojiRegex, + "Emoji name must be alphanumeric, underscores, or dashes, and surrounded by identifiers", + ), + url: ImageOnlyContentFormatSchema, + }) + .strict(), ), }); diff --git a/federation/schemas/extensions/likes.ts b/federation/schemas/extensions/likes.ts new file mode 100644 index 0000000..53ee827 --- /dev/null +++ b/federation/schemas/extensions/likes.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { EntitySchema } from "../base"; + +/** + * @description Like entity + * @see https://versia.pub/extensions/likes + * @example + * { + * "id": "3e7e4750-afd4-4d99-a256-02f0710a0520", + * "type": "pub.versia:likes/Like", + * "created_at": "2021-01-01T00:00:00.000Z", + * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe", + * "uri": "https://example.com/likes/3e7e4750-afd4-4d99-a256-02f0710a0520", + * "liked": "https://otherexample.org/notes/fmKZ763jzIU8" + * } + */ +export const LikeSchema = EntitySchema.extend({ + type: z.literal("pub.versia:likes/Like"), + author: z.string().url(), + liked: z.string().url(), +}); + +/** + * @description Dislike entity + * @see https://versia.pub/extensions/likes + * @example + * { + * "id": "3e7e4750-afd4-4d99-a256-02f0710a0520", + * "type": "pub.versia:likes/Dislike", + * "created_at": "2021-01-01T00:00:00.000Z", + * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe", + * "uri": "https://example.com/dislikes/3e7e4750-afd4-4d99-a256-02f0710a0520", + * "disliked": "https://otherexample.org/notes/fmKZ763jzIU8" + * } + */ +export const DislikeSchema = EntitySchema.extend({ + type: z.literal("pub.versia:likes/Dislike"), + author: z.string().url(), + disliked: z.string().url(), +}); diff --git a/federation/schemas/extensions/polls.ts b/federation/schemas/extensions/polls.ts index fb16238..db4accf 100644 --- a/federation/schemas/extensions/polls.ts +++ b/federation/schemas/extensions/polls.ts @@ -5,88 +5,29 @@ * @see https://versia.pub/extensions/polls */ import { z } from "zod"; -import { ExtensionSchema } from "../base"; -import { ContentFormatSchema } from "../content_format"; - -/** - * @description Poll extension entity - * @see https://versia.pub/extensions/polls - * @example - * { - * "type": "Extension", - * "id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3", - * "extension_type": "org.lysand:polls/Poll", - * "uri": "https://example.com/polls/d6eb84ea-cd13-43f9-9c54-01244da8e5e3", - * "options": [ - * { - * "text/plain": { - * "content": "Red" - * } - * }, - * { - * "text/plain": { - * "content": "Blue" - * } - * }, - * { - * "text/plain": { - * "content": "Green" - * } - * } - * ], - * "votes": [ - * 9, - * 5, - * 0 - * ], - * "multiple_choice": false, - * "expires_at": "2021-01-04T00:00:00.000Z" - * } - */ -export const PollSchema = ExtensionSchema.extend({ - extension_type: z.literal("org.lysand:polls/Poll"), - options: z.array(ContentFormatSchema), - votes: z.array(z.number().int().nonnegative()), - multiple_choice: z.boolean().optional().nullable(), - expires_at: z.string(), -}); +import { EntitySchema } from "../base"; /** * @description Vote extension entity * @see https://versia.pub/extensions/polls * @example * { - * "type": "Extension", - * "id": "31c4de70-e266-4f61-b0f7-3767d3ccf565", + * "id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9", + * "type": "pub.versia:polls/Vote", + * "uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9", * "created_at": "2021-01-01T00:00:00.000Z", - * "uri": "https://example.com/votes/31c4de70-e266-4f61-b0f7-3767d3ccf565", - * "extension_type": "org.lysand:polls/Vote", - * "poll": "https://example.com/polls/31c4de70-e266-4f61-b0f7-3767d3ccf565", + * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe", + * "poll": "https://example.com/notes/f08a124e-fe90-439e-8be4-15a428a72a19", * "option": 1 * } */ -export const VoteSchema = ExtensionSchema.extend({ - extension_type: z.literal("org.lysand:polls/Vote"), - poll: z.string().url(), - option: z.number(), -}); - -/** - * @description Vote result extension entity - * @see https://versia.pub/extensions/polls - * @example - * { - * "type": "Extension", - * "id": "c6d5755b-f42c-418f-ab53-2ee3705d6628", - * "created_at": "2021-01-01T00:00:00.000Z", - * "uri": "https://example.com/polls/c6d5755b-f42c-418f-ab53-2ee3705d6628/result", - * "extension_type": "org.lysand:polls/VoteResult", - * "poll": "https://example.com/polls/c6d5755b-f42c-418f-ab53-2ee3705d6628", - * "votes": [9, 5, 0] - * } - */ -export const VoteResultSchema = ExtensionSchema.extend({ - extension_type: z.literal("org.lysand:polls/VoteResult"), +export const VoteSchema = EntitySchema.extend({ + type: z.literal("pub.versia:polls/Vote"), + author: z.string().url(), poll: z.string().url(), - votes: z.array(z.number().int().nonnegative()), + option: z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1), }); diff --git a/federation/schemas/extensions/reactions.ts b/federation/schemas/extensions/reactions.ts index 6291d39..fb15e80 100644 --- a/federation/schemas/extensions/reactions.ts +++ b/federation/schemas/extensions/reactions.ts @@ -5,24 +5,25 @@ * @see https://versia.pub/extensions/reactions */ import { z } from "zod"; -import { ExtensionSchema } from "../base"; +import { EntitySchema } from "../base"; /** * @description Reaction extension entity * @see https://versia.pub/extensions/reactions * @example * { - * "type": "Extension", - * "id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3", + * "id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9", + * "type": "pub.versia:reactions/Reaction", + * "uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9", * "created_at": "2021-01-01T00:00:00.000Z", - * "uri": "https://example.com/reactions/d6eb84ea-cd13-43f9-9c54-01244da8e5e3", - * "extension_type": "org.lysand:reactions/Reaction", - * "object": "https://example.com/posts/d6eb84ea-cd13-43f9-9c54-01244da8e5e3", - * "content": "👍" + * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe", + * "object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19", + * "content": "😀", * } */ -export const ReactionSchema = ExtensionSchema.extend({ - extension_type: z.literal("org.lysand:reactions/Reaction"), +export const ReactionSchema = EntitySchema.extend({ + type: z.literal("pub.versia:reactions/Reaction"), + author: z.string().url(), object: z.string().url(), - content: z.string(), + content: z.string().min(1).max(256), }); diff --git a/federation/schemas/extensions/share.ts b/federation/schemas/extensions/share.ts new file mode 100644 index 0000000..3209b38 --- /dev/null +++ b/federation/schemas/extensions/share.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { EntitySchema } from "../base"; + +/** + * @description Share entity + * @see https://versia.pub/extensions/share + * @example + * { + * "id": "3e7e4750-afd4-4d99-a256-02f0710a0520", + * "type": "pub.versia:share/Share", + * "created_at": "2021-01-01T00:00:00.000Z", + * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe", + * "uri": "https://example.com/shares/3e7e4750-afd4-4d99-a256-02f0710a0520", + * "shared": "https://otherexample.org/notes/fmKZ763jzIU8" + * } + */ +export const ShareSchema = EntitySchema.extend({ + type: z.literal("pub.versia:share/Share"), + author: z.string().url(), + shared: z.string().url(), +}); diff --git a/federation/schemas/extensions/vanity.ts b/federation/schemas/extensions/vanity.ts index a1810d7..f6ba3dd 100644 --- a/federation/schemas/extensions/vanity.ts +++ b/federation/schemas/extensions/vanity.ts @@ -6,7 +6,11 @@ */ import { z } from "zod"; -import { ContentFormatSchema } from "../content_format"; +import { + AudioOnlyContentFormatSchema, + ImageOnlyContentFormatSchema, +} from "../content_format"; +import { isISOString } from "../regex"; /** * @description Vanity extension entity @@ -17,29 +21,31 @@ import { ContentFormatSchema } from "../content_format"; * "type": "User", * // ... * "extensions": { - * "org.lysand:vanity": { - * "avatar_overlay": { - * "image/png": { - * "content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png", - * "content_type": "image/png" + * "pub.versia:vanity": { + * "avatar_overlays": [ + * { + * "image/png": { + * "content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png", + * "remote": true, + * } * } - * }, + * ], * "avatar_mask": { * "image/png": { * "content": "https://cdn.example.com/d8c42be1-d0f7-43ef-b4ab-5f614e1beba4/rounded_square.jpeg", - * "content_type": "image/jpeg" + * "remote": true, * } * }, * "background": { * "image/png": { * "content": "https://cdn.example.com/6492ddcd-311e-4921-9567-41b497762b09/untitled-file-0019822.png", - * "content_type": "image/png" + * "remote": true, * } * }, * "audio": { * "audio/mpeg": { * "content": "https://cdn.example.com/4da2f0d4-4728-4819-83e4-d614e4c5bebc/michael-jackson-thriller.mp3", - * "content_type": "audio/mpeg" + * "remote": true, * } * }, * "pronouns": { @@ -56,38 +62,46 @@ import { ContentFormatSchema } from "../content_format"; * }, * "birthday": "1998-04-12", * "location": "+40.6894-074.0447/", - * "activitypub": [ - * "@erikuden@mastodon.de" - * ], * "aliases": [ * "https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a", - * "https://social.lysand.org/users/f565ef02-035d-4974-ba5e-f62a8558331d" + * "https://versia.social/users/f565ef02-035d-4974-ba5e-f62a8558331d" * ] * } * } * } */ -export const VanityExtensionSchema = z.object({ - avatar_overlay: ContentFormatSchema.optional().nullable(), - avatar_mask: ContentFormatSchema.optional().nullable(), - background: ContentFormatSchema.optional().nullable(), - audio: ContentFormatSchema.optional().nullable(), - pronouns: z.record( - z.string(), - z.array( - z.union([ - z.object({ - subject: z.string(), - object: z.string(), - dependent_possessive: z.string(), - independent_possessive: z.string(), - reflexive: z.string(), - }), - z.string(), - ]), +export const VanityExtensionSchema = z + .object({ + avatar_overlays: z + .array(ImageOnlyContentFormatSchema) + .optional() + .nullable(), + avatar_mask: ImageOnlyContentFormatSchema.optional().nullable(), + background: ImageOnlyContentFormatSchema.optional().nullable(), + audio: AudioOnlyContentFormatSchema.optional().nullable(), + pronouns: z.record( + z.string(), + z.array( + z.union([ + z + .object({ + subject: z.string(), + object: z.string(), + dependent_possessive: z.string(), + independent_possessive: z.string(), + reflexive: z.string(), + }) + .strict(), + z.string(), + ]), + ), ), - ), - birthday: z.string().optional().nullable(), - location: z.string().optional().nullable(), - activitypub: z.string().optional().nullable(), -}); + birthday: z + .string() + .refine((v) => isISOString(v), "must be a valid ISO8601 datetime") + .optional() + .nullable(), + location: z.string().optional().nullable(), + aliases: z.array(z.string().url()).optional().nullable(), + }) + .strict(); diff --git a/federation/schemas/regex.ts b/federation/schemas/regex.ts index 5622d38..dc9ac88 100644 --- a/federation/schemas/regex.ts +++ b/federation/schemas/regex.ts @@ -5,13 +5,14 @@ */ import { - caseInsensitive, charIn, + charNotIn, createRegExp, digit, exactly, global, letter, + not, oneOrMore, } from "magic-regexp"; @@ -19,14 +20,21 @@ import { * Regular expression for matching emojis. */ export const emojiRegex: RegExp = createRegExp( - // A-Z a-z 0-9 _ - - oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))), - [caseInsensitive, global], + exactly( + exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1), + oneOrMore(letter.or(digit).or(charIn("_-"))), + exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1), + ), + [global], +); + +export const semverRegex: RegExp = new RegExp( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm, ); /** * Regular expression for matching an extension_type - * @example org.lysand:custom_emojis/Emoji + * @example pub.versia:custom_emojis/Emoji */ export const extensionTypeRegex: RegExp = createRegExp( // org namespace, then colon, then alphanumeric/_/-, then extension name @@ -38,3 +46,21 @@ export const extensionTypeRegex: RegExp = createRegExp( oneOrMore(exactly(letter.or(digit).or(charIn("_-")))), ), ); + +/** + * Regular expression for matching an extension + * @example pub.versia:custom_emojis + */ +export const extensionRegex: RegExp = createRegExp( + // org namespace, then colon, then alphanumeric/_/-, then extension name + exactly( + oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-.")))), + exactly(":"), + oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-")))), + ), +); + +export const isISOString = (val: string | Date) => { + const d = new Date(val); + return !Number.isNaN(d.valueOf()) && d.toISOString() === val; +}; diff --git a/federation/validator.ts b/federation/validator.ts index bec3345..f93e26d 100644 --- a/federation/validator.ts +++ b/federation/validator.ts @@ -1,23 +1,13 @@ import type { z } from "zod"; import { fromError } from "zod-validation-error"; import { - ActionSchema, - ActorPublicKeyDataSchema, - DislikeSchema, EntitySchema, - ExtensionSchema, FollowAcceptSchema, FollowRejectSchema, FollowSchema, - LikeSchema, NoteSchema, - PatchSchema, - PublicationSchema, - ReportSchema, - ServerMetadataSchema, - UndoSchema, + PublicKeyDataSchema, UserSchema, - VisibilitySchema, } from "./schemas/base"; import { ContentFormatSchema } from "./schemas/content_format"; import { ExtensionPropertySchema } from "./schemas/extensions"; @@ -81,15 +71,6 @@ export class EntityValidator { return this.validate(NoteSchema, data); } - /** - * Validates a Patch entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Patch(data: unknown): Promise> { - return this.validate(PatchSchema, data); - } - /** * Validates an ActorPublicKeyData entity. * @param data - The data to validate @@ -97,8 +78,8 @@ export class EntityValidator { */ public ActorPublicKeyData( data: unknown, - ): Promise> { - return this.validate(ActorPublicKeyDataSchema, data); + ): Promise> { + return this.validate(PublicKeyDataSchema, data); } /** @@ -121,42 +102,6 @@ export class EntityValidator { return this.validate(UserSchema, data); } - /** - * Validates an Action entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Action(data: unknown): Promise> { - return this.validate(ActionSchema, data); - } - - /** - * Validates a Like entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Like(data: unknown): Promise> { - return this.validate(LikeSchema, data); - } - - /** - * Validates an Undo entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Undo(data: unknown): Promise> { - return this.validate(UndoSchema, data); - } - - /** - * Validates a Dislike entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Dislike(data: unknown): Promise> { - return this.validate(DislikeSchema, data); - } - /** * Validates a Follow entity. * @param data - The data to validate @@ -188,37 +133,6 @@ export class EntityValidator { return this.validate(FollowRejectSchema, data); } - /** - * Validates an Extension entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Extension( - data: unknown, - ): Promise> { - return this.validate(ExtensionSchema, data); - } - - /** - * Validates a Report entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Report(data: unknown): Promise> { - return this.validate(ReportSchema, data); - } - - /** - * Validates a ServerMetadata entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public ServerMetadata( - data: unknown, - ): Promise> { - return this.validate(ServerMetadataSchema, data); - } - /** * Validates a ContentFormat entity. * @param data - The data to validate @@ -241,28 +155,6 @@ export class EntityValidator { return this.validate(CustomEmojiExtensionSchema, data); } - /** - * Validates a Visibility entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Visibility( - data: unknown, - ): Promise> { - return this.validate(VisibilitySchema, data); - } - - /** - * Validates a Publication entity. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public Publication( - data: unknown, - ): Promise> { - return this.validate(PublicationSchema, data); - } - /** * Validates an Entity. * @param data - The data to validate