diff --git a/cli/user/refetch.ts b/cli/user/refetch.ts index f805cf4d..376fb9c9 100644 --- a/cli/user/refetch.ts +++ b/cli/user/refetch.ts @@ -28,7 +28,10 @@ export const refetchUserCommand = defineCommand( const spinner = ora("Refetching user").start(); try { - await User.fromVersia(user.uri); + await User.fromVersia( + user.reference, + user.reference.domain as string, + ); } catch (error) { spinner.fail( `Failed to refetch user ${chalk.gray(user.data.username)}`, diff --git a/cli/utils.ts b/cli/utils.ts index b75ffc06..2fc6b494 100644 --- a/cli/utils.ts +++ b/cli/utils.ts @@ -8,7 +8,7 @@ export const retrieveUser = async ( ): Promise => { const { username, domain } = parseUserAddress(usernameOrHandle); - const instance = domain ? await Instance.resolveFromHost(domain) : null; + const instance = domain ? await Instance.resolve(domain) : null; const user = await User.fromSql( and( diff --git a/packages/api/routes/api/v1/accounts/[id]/refetch.ts b/packages/api/routes/api/v1/accounts/[id]/refetch.ts index c817269d..5f3d6577 100644 --- a/packages/api/routes/api/v1/accounts/[id]/refetch.ts +++ b/packages/api/routes/api/v1/accounts/[id]/refetch.ts @@ -50,7 +50,10 @@ export default apiRoute((app) => throw new ApiError(400, "Cannot refetch a local user"); } - const newUser = await User.fromVersia(otherUser.uri); + const newUser = await User.fromVersia( + otherUser.reference, + otherUser.reference.domain as string, + ); return context.json(newUser.toApi(false), 200); }, diff --git a/packages/api/routes/api/v1/accounts/lookup/index.ts b/packages/api/routes/api/v1/accounts/lookup/index.ts index b247d3fd..3122e1b6 100644 --- a/packages/api/routes/api/v1/accounts/lookup/index.ts +++ b/packages/api/routes/api/v1/accounts/lookup/index.ts @@ -2,6 +2,7 @@ import { Account as AccountSchema, RolePermission, } from "@versia/client/schemas"; +import * as VersiaEntities from "@versia/sdk/entities"; import { config } from "@versia-server/config"; import { ApiError } from "@versia-server/kit"; import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; @@ -73,7 +74,7 @@ export default apiRoute((app) => // User is remote // Try to fetch it from database - const instance = await Instance.resolveFromHost(domain); + const instance = await Instance.resolve(domain); if (!instance) { return context.json( @@ -100,13 +101,17 @@ export default apiRoute((app) => throw ApiError.accountNotFound(); } - const foundAccount = await User.resolve(uri); + const accountData = await Instance.federationRequester.fetchSigned( + uri, + VersiaEntities.User, + ); - if (foundAccount) { - return context.json(foundAccount.toApi(), 200); - } + const foundAccount = await User.fromVersia( + accountData, + instance.data.baseUrl, + ); - throw ApiError.accountNotFound(); + return context.json(foundAccount.toApi(), 200); }, ), ); diff --git a/packages/api/routes/api/v1/accounts/search/index.ts b/packages/api/routes/api/v1/accounts/search/index.ts index 4dbadffa..ee787542 100644 --- a/packages/api/routes/api/v1/accounts/search/index.ts +++ b/packages/api/routes/api/v1/accounts/search/index.ts @@ -3,9 +3,10 @@ import { RolePermission, zBoolean, } from "@versia/client/schemas"; +import * as VersiaEntities from "@versia/sdk/entities"; import { ApiError } from "@versia-server/kit"; import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; -import { User } from "@versia-server/kit/db"; +import { Instance, User } from "@versia-server/kit/db"; import { parseUserAddress } from "@versia-server/kit/parsers"; import { Users } from "@versia-server/kit/tables"; import { eq, ilike, not, or, sql } from "drizzle-orm"; @@ -88,14 +89,22 @@ export default apiRoute((app) => const accounts: User[] = []; if (resolve && domain) { + const instance = await Instance.resolve(domain); const uri = await User.webFinger(username, domain); if (uri) { - const resolvedUser = await User.resolve(uri); + const accountData = + await Instance.federationRequester.fetchSigned( + uri, + VersiaEntities.User, + ); - if (resolvedUser) { - accounts.push(resolvedUser); - } + const foundAccount = await User.fromVersia( + accountData, + instance.data.baseUrl, + ); + + accounts.push(foundAccount); } } else { accounts.push( diff --git a/packages/api/routes/api/v2/search/index.ts b/packages/api/routes/api/v2/search/index.ts index 47bccfbb..432e3faf 100644 --- a/packages/api/routes/api/v2/search/index.ts +++ b/packages/api/routes/api/v2/search/index.ts @@ -6,10 +6,11 @@ import { userAddressRegex, zBoolean, } from "@versia/client/schemas"; +import * as VersiaEntities from "@versia/sdk/entities"; import { config } from "@versia-server/config"; import { ApiError } from "@versia-server/kit"; import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; -import { db, Note, User } from "@versia-server/kit/db"; +import { db, Instance, Note, User } from "@versia-server/kit/db"; import { parseUserAddress } from "@versia-server/kit/parsers"; import { searchManager } from "@versia-server/kit/search"; import { Instances, Notes, Users } from "@versia-server/kit/tables"; @@ -187,21 +188,29 @@ export default apiRoute((app) => } if (resolve && domain) { + const instance = await Instance.resolve(domain); const uri = await User.webFinger(username, domain); if (uri) { - const newUser = await User.resolve(uri); - - if (newUser) { - return context.json( - { - accounts: [newUser.toApi()], - statuses: [], - hashtags: [], - }, - 200, + const accountData = + await Instance.federationRequester.fetchSigned( + uri, + VersiaEntities.User, ); - } + + const newUser = await User.fromVersia( + accountData, + instance.data.baseUrl, + ); + + return context.json( + { + accounts: [newUser.toApi()], + statuses: [], + hashtags: [], + }, + 200, + ); } } } diff --git a/packages/api/routes/likes/[uuid]/index.ts b/packages/api/routes/likes/[uuid]/index.ts deleted file mode 100644 index a0d99ba0..00000000 --- a/packages/api/routes/likes/[uuid]/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Status as StatusSchema } from "@versia/client/schemas"; -import { LikeSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { Like, User } from "@versia-server/kit/db"; -import { Likes } from "@versia-server/kit/tables"; -import { and, eq, sql } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/likes/:id", - describeRoute({ - summary: "Retrieve the Versia representation of a like.", - tags: ["Federation"], - responses: { - 200: { - description: "Like", - content: { - "application/json": { - schema: resolver(LikeSchema), - }, - }, - }, - 404: { - description: - "Entity not found, is remote, or the requester is not allowed to view it.", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ id: StatusSchema.shape.id }), - handleZodError, - ), - async (context) => { - const { id } = context.req.valid("param"); - - // Don't fetch a like of a note that is not public or unlisted - // prevents leaking the existence of a private note - const like = await Like.fromSql( - and( - eq(Likes.id, id), - sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`, - ), - ); - - if (!like) { - throw ApiError.likeNotFound(); - } - - const liker = await User.fromId(like.data.likerId); - - if (!liker || liker.remote) { - throw ApiError.accountNotFound(); - } - - // If base_url uses https and request uses http, rewrite request to use https - // This fixes reverse proxy errors - const reqUrl = new URL(context.req.url); - if ( - config.http.base_url.protocol === "https:" && - reqUrl.protocol === "http:" - ) { - reqUrl.protocol = "https:"; - } - - const { headers } = await liker.sign( - like.toVersia(), - reqUrl, - "GET", - ); - - return context.json(like.toVersia(), 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/notes/[uuid]/index.ts b/packages/api/routes/notes/[uuid]/index.ts deleted file mode 100644 index 4d57f219..00000000 --- a/packages/api/routes/notes/[uuid]/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Status as StatusSchema } from "@versia/client/schemas"; -import { NoteSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { Note } from "@versia-server/kit/db"; -import { Notes } from "@versia-server/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/notes/:id", - describeRoute({ - summary: "Retrieve the Versia representation of a note.", - tags: ["Federation"], - responses: { - 200: { - description: "Note", - content: { - "application/json": { - schema: resolver(NoteSchema), - }, - }, - }, - 404: { - description: - "Entity not found, is remote, or the requester is not allowed to view it.", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - id: StatusSchema.shape.id, - }), - handleZodError, - ), - async (context) => { - const { id } = context.req.valid("param"); - - const note = await Note.fromSql( - and( - eq(Notes.id, id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - if (!(note && (await note.isViewableByUser(null))) || note.remote) { - throw ApiError.noteNotFound(); - } - - // If base_url uses https and request uses http, rewrite request to use https - // This fixes reverse proxy errors - const reqUrl = new URL(context.req.url); - if ( - config.http.base_url.protocol === "https:" && - reqUrl.protocol === "http:" - ) { - reqUrl.protocol = "https:"; - } - - const { headers } = await note.author.sign( - note.toVersia(), - reqUrl, - "GET", - ); - - return context.json(note.toVersia(), 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/notes/[uuid]/quotes.ts b/packages/api/routes/notes/[uuid]/quotes.ts deleted file mode 100644 index 6497cc27..00000000 --- a/packages/api/routes/notes/[uuid]/quotes.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Status as StatusSchema } from "@versia/client/schemas"; -import * as VersiaEntities from "@versia/sdk/entities"; -import { URICollectionSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { db, Note } from "@versia-server/kit/db"; -import { Notes } from "@versia-server/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/notes/:id/quotes", - describeRoute({ - summary: "Retrieve all quotes of a Versia Note.", - tags: ["Federation"], - responses: { - 200: { - description: "Note quotes", - content: { - "application/json": { - schema: resolver(URICollectionSchema), - }, - }, - }, - 404: { - description: - "Entity not found, is remote, or the requester is not allowed to view it.", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - id: StatusSchema.shape.id, - }), - handleZodError, - ), - validator( - "query", - z.object({ - limit: z.coerce.number().int().min(1).max(100).default(40), - offset: z.coerce.number().int().nonnegative().default(0), - }), - handleZodError, - ), - async (context) => { - const { id } = context.req.valid("param"); - const { limit, offset } = context.req.valid("query"); - - const note = await Note.fromSql( - and( - eq(Notes.id, id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - if (!(note && (await note.isViewableByUser(null))) || note.remote) { - throw ApiError.noteNotFound(); - } - - const quotes = await Note.manyFromSql( - and( - eq(Notes.quotingId, note.id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - undefined, - limit, - offset, - ); - - const quoteCount = await db.$count( - Notes, - and( - eq(Notes.quotingId, note.id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - const uriCollection = new VersiaEntities.URICollection({ - author: note.author.uri.href, - first: new URL( - `/notes/${note.id}/quotes?offset=0`, - config.http.base_url, - ).href, - last: - quoteCount > limit - ? new URL( - `/notes/${note.id}/quotes?offset=${ - quoteCount - limit - }`, - config.http.base_url, - ).href - : new URL( - `/notes/${note.id}/quotes`, - config.http.base_url, - ).href, - next: - offset + limit < quoteCount - ? new URL( - `/notes/${note.id}/quotes?offset=${ - offset + limit - }`, - config.http.base_url, - ).href - : null, - previous: - offset - limit >= 0 - ? new URL( - `/notes/${note.id}/quotes?offset=${ - offset - limit - }`, - config.http.base_url, - ).href - : null, - total: quoteCount, - items: quotes.map((reply) => reply.getUri().href), - }); - - // If base_url uses https and request uses http, rewrite request to use https - // This fixes reverse proxy errors - const reqUrl = new URL(context.req.url); - if ( - config.http.base_url.protocol === "https:" && - reqUrl.protocol === "http:" - ) { - reqUrl.protocol = "https:"; - } - - const { headers } = await note.author.sign( - uriCollection, - reqUrl, - "GET", - ); - - return context.json(uriCollection, 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/notes/[uuid]/replies.ts b/packages/api/routes/notes/[uuid]/replies.ts deleted file mode 100644 index 2721a666..00000000 --- a/packages/api/routes/notes/[uuid]/replies.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Status as StatusSchema } from "@versia/client/schemas"; -import * as VersiaEntities from "@versia/sdk/entities"; -import { URICollectionSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { db, Note } from "@versia-server/kit/db"; -import { Notes } from "@versia-server/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/notes/:id/replies", - describeRoute({ - summary: "Retrieve all replies to a Versia Note.", - tags: ["Federation"], - responses: { - 200: { - description: "Note replies", - content: { - "application/json": { - schema: resolver(URICollectionSchema), - }, - }, - }, - 404: { - description: - "Entity not found, is remote, or the requester is not allowed to view it.", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ id: StatusSchema.shape.id }), - handleZodError, - ), - validator( - "query", - z.object({ - limit: z.coerce.number().int().min(1).max(100).default(40), - offset: z.coerce.number().int().nonnegative().default(0), - }), - handleZodError, - ), - async (context) => { - const { id } = context.req.valid("param"); - const { limit, offset } = context.req.valid("query"); - - const note = await Note.fromSql( - and( - eq(Notes.id, id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - if (!(note && (await note.isViewableByUser(null))) || note.remote) { - throw ApiError.noteNotFound(); - } - - const replies = await Note.manyFromSql( - and( - eq(Notes.replyId, note.id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - undefined, - limit, - offset, - ); - - const replyCount = await db.$count( - Notes, - and( - eq(Notes.replyId, note.id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - const uriCollection = new VersiaEntities.URICollection({ - author: note.author.uri.href, - first: new URL( - `/notes/${note.id}/replies?offset=0`, - config.http.base_url, - ).href, - last: - replyCount > limit - ? new URL( - `/notes/${note.id}/replies?offset=${ - replyCount - limit - }`, - config.http.base_url, - ).href - : new URL( - `/notes/${note.id}/replies`, - config.http.base_url, - ).href, - next: - offset + limit < replyCount - ? new URL( - `/notes/${note.id}/replies?offset=${ - offset + limit - }`, - config.http.base_url, - ).href - : null, - previous: - offset - limit >= 0 - ? new URL( - `/notes/${note.id}/replies?offset=${ - offset - limit - }`, - config.http.base_url, - ).href - : null, - total: replyCount, - items: replies.map((reply) => reply.getUri().href), - }); - - // If base_url uses https and request uses http, rewrite request to use https - // This fixes reverse proxy errors - const reqUrl = new URL(context.req.url); - if ( - config.http.base_url.protocol === "https:" && - reqUrl.protocol === "http:" - ) { - reqUrl.protocol = "https:"; - } - - const { headers } = await note.author.sign( - uriCollection, - reqUrl, - "GET", - ); - - return context.json(uriCollection, 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/notes/[uuid]/shares.ts b/packages/api/routes/notes/[uuid]/shares.ts deleted file mode 100644 index 18be1fa4..00000000 --- a/packages/api/routes/notes/[uuid]/shares.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Status as StatusSchema } from "@versia/client/schemas"; -import * as VersiaEntities from "@versia/sdk/entities"; -import { URICollectionSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { db, Note } from "@versia-server/kit/db"; -import { Notes } from "@versia-server/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/notes/:id/shares", - describeRoute({ - summary: "Retrieve all shares of a Versia Note.", - tags: ["Federation"], - responses: { - 200: { - description: "Note shares", - content: { - "application/json": { - schema: resolver(URICollectionSchema), - }, - }, - }, - 404: { - description: - "Entity not found, is remote, or the requester is not allowed to view it.", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - id: StatusSchema.shape.id, - }), - handleZodError, - ), - validator( - "query", - z.object({ - limit: z.coerce.number().int().min(1).max(100).default(40), - offset: z.coerce.number().int().nonnegative().default(0), - }), - handleZodError, - ), - async (context) => { - const { id } = context.req.valid("param"); - const { limit, offset } = context.req.valid("query"); - - const note = await Note.fromSql( - and( - eq(Notes.id, id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - if (!(note && (await note.isViewableByUser(null))) || note.remote) { - throw ApiError.noteNotFound(); - } - - const shares = await Note.manyFromSql( - and( - eq(Notes.reblogId, note.id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - undefined, - limit, - offset, - ); - - const shareCount = await db.$count( - Notes, - and( - eq(Notes.reblogId, note.id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - const uriCollection = new VersiaEntities.URICollection({ - author: note.author.uri.href, - first: new URL( - `/notes/${note.id}/shares?offset=0`, - config.http.base_url, - ).href, - last: - shareCount > limit - ? new URL( - `/notes/${note.id}/shares?offset=${ - shareCount - limit - }`, - config.http.base_url, - ).href - : new URL( - `/notes/${note.id}/shares`, - config.http.base_url, - ).href, - next: - offset + limit < shareCount - ? new URL( - `/notes/${note.id}/shares?offset=${ - offset + limit - }`, - config.http.base_url, - ).href - : null, - previous: - offset - limit >= 0 - ? new URL( - `/notes/${note.id}/shares?offset=${ - offset - limit - }`, - config.http.base_url, - ).href - : null, - total: shareCount, - items: shares.map( - (share) => - new URL(`/shares/${share.id}`, config.http.base_url) - .href, - ), - }); - - // If base_url uses https and request uses http, rewrite request to use https - // This fixes reverse proxy errors - const reqUrl = new URL(context.req.url); - if ( - config.http.base_url.protocol === "https:" && - reqUrl.protocol === "http:" - ) { - reqUrl.protocol = "https:"; - } - - const { headers } = await note.author.sign( - uriCollection, - reqUrl, - "GET", - ); - - return context.json(uriCollection, 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/shares/[uuid]/index.ts b/packages/api/routes/shares/[uuid]/index.ts deleted file mode 100644 index 4c5b376f..00000000 --- a/packages/api/routes/shares/[uuid]/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Status as StatusSchema } from "@versia/client/schemas"; -import { ShareSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { Note } from "@versia-server/kit/db"; -import { Notes } from "@versia-server/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/shares/:id", - describeRoute({ - summary: "Retrieve the Versia representation of a share.", - tags: ["Federation"], - responses: { - 200: { - description: "Share", - content: { - "application/json": { - schema: resolver(ShareSchema), - }, - }, - }, - 404: { - description: - "Entity not found, is remote, or the requester is not allowed to view it.", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - id: StatusSchema.shape.id, - }), - handleZodError, - ), - async (context) => { - const { id } = context.req.valid("param"); - - const note = await Note.fromSql( - and( - eq(Notes.id, id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - if (!(note && (await note.isViewableByUser(null))) || note.remote) { - throw ApiError.noteNotFound(); - } - - // If base_url uses https and request uses http, rewrite request to use https - // This fixes reverse proxy errors - const reqUrl = new URL(context.req.url); - if ( - config.http.base_url.protocol === "https:" && - reqUrl.protocol === "http:" - ) { - reqUrl.protocol = "https:"; - } - - const { headers } = await note.author.sign( - note.toVersiaShare(), - reqUrl, - "GET", - ); - - return context.json(note.toVersiaShare(), 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/users/[uuid]/inbox/index.ts b/packages/api/routes/users/[uuid]/inbox/index.ts deleted file mode 100644 index 7c7a376d..00000000 --- a/packages/api/routes/users/[uuid]/inbox/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { InboxJobType, inboxQueue } from "@versia-server/kit/queues/inbox"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.post( - "/users/:uuid/inbox", - describeRoute({ - summary: "Receive federation inbox", - tags: ["Federation"], - responses: { - 200: { - description: "Request processed", - }, - 201: { - description: "Request accepted", - }, - 400: { - description: "Bad request", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - 401: { - description: "Signature could not be verified", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - 403: { - description: "Cannot view users from remote instances", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - 500: { - description: "Internal server error", - content: { - "application/json": { - schema: resolver( - z.object({ - error: z.string(), - message: z.string(), - }), - ), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - uuid: z.uuid(), - }), - handleZodError, - ), - validator( - "header", - z.object({ - "versia-signature": z.string().optional(), - "versia-signed-at": z.coerce.number().optional(), - "versia-signed-by": z - .url() - .or(z.string().startsWith("instance ")) - .optional(), - authorization: z.string().optional(), - }), - handleZodError, - ), - async (context) => { - const body = await context.req.json(); - const { - "versia-signature": signature, - "versia-signed-at": signedAt, - "versia-signed-by": signedBy, - authorization, - } = context.req.valid("header"); - - await inboxQueue.add(InboxJobType.ProcessEntity, { - data: body, - headers: { - "versia-signature": signature, - "versia-signed-at": signedAt, - "versia-signed-by": signedBy, - authorization, - }, - request: { - body: await context.req.text(), - method: context.req.method, - url: context.req.url, - }, - ip: context.env.ip ?? null, - }); - - return context.body( - "Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)", - 200, - ); - }, - ), -); diff --git a/packages/api/routes/users/[uuid]/index.ts b/packages/api/routes/users/[uuid]/index.ts deleted file mode 100644 index b47aecb6..00000000 --- a/packages/api/routes/users/[uuid]/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { UserSchema } from "@versia/sdk/schemas"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { User } from "@versia-server/kit/db"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -export default apiRoute((app) => - app.get( - "/users/:uuid", - describeRoute({ - summary: "Get user data", - tags: ["Federation"], - responses: { - 200: { - description: "User data", - content: { - "application/json": { - schema: resolver(UserSchema), - }, - }, - }, - 301: { - description: - "Redirect to user profile (for web browsers). Uses user-agent for detection.", - }, - 404: ApiError.accountNotFound().schema, - 403: { - description: "Cannot view users from remote instances", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - uuid: z.uuid(), - }), - handleZodError, - ), - // @ts-expect-error idk why this is happening and I don't care - async (context) => { - const { uuid } = context.req.valid("param"); - - const user = await User.fromId(uuid); - - if (!user) { - throw ApiError.accountNotFound(); - } - - if (user.remote) { - throw new ApiError(403, "User is not on this instance"); - } - - // Try to detect a web browser and redirect to the user's profile page - if (context.req.header("user-agent")?.includes("Mozilla")) { - return context.redirect(user.toApi().url); - } - - const userJson = user.toVersia(); - - const { headers } = await user.sign( - userJson, - new URL(context.req.url), - "GET", - ); - - return context.json(userJson, 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/users/[uuid]/outbox/index.ts b/packages/api/routes/users/[uuid]/outbox/index.ts deleted file mode 100644 index 0f86b784..00000000 --- a/packages/api/routes/users/[uuid]/outbox/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as VersiaEntities from "@versia/sdk/entities"; -import { CollectionSchema, NoteSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { db, Note, User } from "@versia-server/kit/db"; -import { Notes } from "@versia-server/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod"; - -const NOTES_PER_PAGE = 20; - -export default apiRoute((app) => - app.get( - "/users/:uuid/outbox", - describeRoute({ - summary: "Get user outbox", - tags: ["Federation"], - responses: { - 200: { - description: "User outbox", - content: { - "application/json": { - schema: resolver( - CollectionSchema.extend({ - items: z.array(NoteSchema), - }), - ), - }, - }, - }, - 404: { - description: "User not found", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - 403: { - description: "Cannot view users from remote instances", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - uuid: z.uuid(), - }), - handleZodError, - ), - validator( - "query", - z.object({ - page: z.string().optional(), - }), - handleZodError, - ), - async (context) => { - const { uuid } = context.req.valid("param"); - - const author = await User.fromId(uuid); - - if (!author) { - throw new ApiError(404, "User not found"); - } - - if (author.remote) { - throw new ApiError(403, "User is not on this instance"); - } - - const pageNumber = Number(context.req.valid("query").page) || 1; - - const notes = await Note.manyFromSql( - and( - eq(Notes.authorId, uuid), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - undefined, - NOTES_PER_PAGE, - NOTES_PER_PAGE * (pageNumber - 1), - ); - - const totalNotes = await db.$count( - Notes, - and( - eq(Notes.authorId, uuid), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - const json = new VersiaEntities.Collection({ - first: new URL( - `/users/${uuid}/outbox?page=1`, - config.http.base_url, - ).href, - last: new URL( - `/users/${uuid}/outbox?page=${Math.ceil( - totalNotes / NOTES_PER_PAGE, - )}`, - config.http.base_url, - ).href, - total: totalNotes, - author: author.uri.href, - next: - notes.length === NOTES_PER_PAGE - ? new URL( - `/users/${uuid}/outbox?page=${pageNumber + 1}`, - config.http.base_url, - ).href - : null, - previous: - pageNumber > 1 - ? new URL( - `/users/${uuid}/outbox?page=${pageNumber - 1}`, - config.http.base_url, - ).href - : null, - items: notes.map((note) => note.toVersia()), - }); - - const { headers } = await author.sign( - json, - new URL(context.req.url), - "GET", - ); - - return context.json(json, 200, headers.toJSON()); - }, - ), -); diff --git a/packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts b/packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts new file mode 100644 index 00000000..d4e5a217 --- /dev/null +++ b/packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts @@ -0,0 +1,324 @@ +import * as VersiaEntities from "@versia/sdk/entities"; +import { + CollectionSchema, + EntitySchema, + URICollectionSchema, +} from "@versia/sdk/schemas"; +import { ApiError } from "@versia-server/kit"; +import { apiRoute, handleZodError } from "@versia-server/kit/api"; +import { db, Instance, Note, User } from "@versia-server/kit/db"; +import { Notes, Users } from "@versia-server/kit/tables"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { z } from "zod"; + +export default apiRoute((app) => + app.get( + "/.versia/v0.6/entities/:entity_type/:id/collections/:collection_type", + describeRoute({ + summary: + "Retrieve the Versia representation of a collection attached to an entity.", + tags: ["Federation"], + responses: { + 200: { + description: "Collection", + content: { + "application/json": { + schema: resolver( + z.union([ + CollectionSchema, + URICollectionSchema, + ]), + ), + }, + }, + }, + 404: { + description: "Collection not found.", + content: { + "application/json": { + schema: resolver(ApiError.zodSchema), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + entity_type: EntitySchema.shape.type, + id: EntitySchema.shape.id, + collection_type: z.string(), + }), + handleZodError, + ), + validator( + "query", + z.object({ + limit: z.coerce.number().int().min(1).max(40).default(40), + offset: z.coerce.number().int().nonnegative().default(0), + }), + handleZodError, + ), + async (context) => { + const { entity_type, id, collection_type } = + context.req.valid("param"); + const { limit, offset } = context.req.valid("query"); + + let entity: + | VersiaEntities.Collection + | VersiaEntities.URICollection + | null = null; + + switch (entity_type) { + case "Note": { + const note = await Note.fromSql( + and( + eq(Notes.id, id), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + ); + + if ( + !(note && (await note.isViewableByUser(null))) || + note.remote + ) { + throw ApiError.noteNotFound(); + } + + switch (collection_type) { + case "replies": { + const replies = await Note.manyFromSql( + and( + eq(Notes.replyId, note.id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + undefined, + limit, + offset, + ); + + const replyCount = await db.$count( + Notes, + and( + eq(Notes.replyId, note.id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + ); + + entity = new VersiaEntities.URICollection({ + author: note.author.id, + total: replyCount, + items: replies.map((reply) => + reply.reference.toString(), + ), + }); + break; + } + case "quotes": { + const quotes = await Note.manyFromSql( + and( + eq(Notes.quotingId, note.id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + undefined, + limit, + offset, + ); + + const quoteCount = await db.$count( + Notes, + and( + eq(Notes.quotingId, note.id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + ); + + entity = new VersiaEntities.URICollection({ + author: note.author.id, + total: quoteCount, + items: quotes.map((quote) => + quote.reference.toString(), + ), + }); + break; + } + case "pub.versia:share/Shares": { + const shares = await Note.manyFromSql( + and( + eq(Notes.reblogId, note.id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + undefined, + limit, + offset, + ); + + const shareCount = await db.$count( + Notes, + and( + eq(Notes.reblogId, note.id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + ); + + entity = new VersiaEntities.URICollection({ + author: note.author.id, + total: shareCount, + items: shares.map((share) => + share.reference.toString(), + ), + }); + break; + } + } + break; + } + + case "User": { + const user = await User.fromId(id); + + if (!user || user.remote) { + throw ApiError.notFound(); + } + + switch (collection_type) { + case "outbox": { + const total = await db.$count( + Notes, + and( + eq(Notes.authorId, id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + ); + + const outboxItems = await Note.manyFromSql( + and( + eq(Notes.authorId, id), + inArray(Notes.visibility, [ + "public", + "unlisted", + ]), + ), + undefined, + limit, + offset, + ); + + entity = new VersiaEntities.Collection({ + author: user.id, + total, + items: outboxItems.map((note) => + note.toVersia(), + ), + }); + break; + } + + case "followers": { + if (user.data.isHidingCollections) { + entity = new VersiaEntities.URICollection({ + author: user.id, + items: [], + total: 0, + }); + break; + } + + const total = await db.$count( + Users, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, + ); + + const followers = await User.manyFromSql( + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, + undefined, + limit, + offset, + ); + + entity = new VersiaEntities.URICollection({ + author: user.id, + items: followers.map((follower) => + follower.reference.toString(), + ), + total, + }); + break; + } + + case "following": { + if (user.data.isHidingCollections) { + entity = new VersiaEntities.URICollection({ + author: user.id, + items: [], + total: 0, + }); + break; + } + + const total = await db.$count( + Users, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${user.id} AND "Relationships"."subjectId" = ${Users.id} AND "Relationships"."following" = true)`, + ); + + const following = await User.manyFromSql( + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${user.id} AND "Relationships"."subjectId" = ${Users.id} AND "Relationships"."following" = true)`, + undefined, + limit, + offset, + ); + + entity = new VersiaEntities.URICollection({ + author: user.id, + items: following.map((followed) => + followed.reference.toString(), + ), + total, + }); + break; + } + + default: { + throw ApiError.notFound(); + } + } + break; + } + } + + if (!entity) { + throw ApiError.notFound(); + } + + const { headers } = await Instance.sign( + entity, + new URL(context.req.url), + "GET", + ); + + return context.json(entity, 200, headers.toJSON()); + }, + ), +); diff --git a/packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts b/packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts new file mode 100644 index 00000000..d8d45b01 --- /dev/null +++ b/packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts @@ -0,0 +1,178 @@ +import type * as VersiaEntities from "@versia/sdk/entities"; +import { + DislikeSchema, + EntitySchema, + LikeSchema, + NoteSchema, + ReactionSchema, + ShareSchema, + UserSchema, +} from "@versia/sdk/schemas"; +import { ApiError } from "@versia-server/kit"; +import { apiRoute, handleZodError } from "@versia-server/kit/api"; +import { Instance, Like, Note, Reaction, User } from "@versia-server/kit/db"; +import { Likes, Notes } from "@versia-server/kit/tables"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { z } from "zod"; + +export default apiRoute((app) => + app.get( + "/.versia/v0.6/entities/:entity_type/:id", + describeRoute({ + summary: "Retrieve the Versia representation of an entity.", + tags: ["Federation"], + responses: { + 200: { + description: "Entity", + content: { + "application/json": { + schema: resolver( + z.union([ + NoteSchema, + UserSchema, + LikeSchema, + DislikeSchema, + ReactionSchema, + ShareSchema, + ]), + ), + }, + }, + }, + 301: { + description: + "Redirect to user profile (for web browsers). Uses Accept header for detection.", + }, + 404: { + description: + "Entity not found, is remote, or the requester is not allowed to view it.", + content: { + "application/json": { + schema: resolver(ApiError.zodSchema), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + entity_type: EntitySchema.shape.type, + id: EntitySchema.shape.id, + }), + handleZodError, + ), + async (context) => { + const { entity_type, id } = context.req.valid("param"); + + let entity: + | VersiaEntities.Note + | VersiaEntities.User + | VersiaEntities.Like + | VersiaEntities.Dislike + | VersiaEntities.Reaction + | VersiaEntities.Share + | null = null; + + switch (entity_type) { + case "pub.versia:notes/Note": { + const note = await Note.fromSql( + and( + eq(Notes.id, id), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + ); + + if ( + !(note && (await note.isViewableByUser(null))) || + note.remote + ) { + throw ApiError.noteNotFound(); + } + + entity = note.toVersia(); + break; + } + case "pub.versia:users/User": { + const user = await User.fromId(id); + + if (!user || user.remote) { + throw ApiError.accountNotFound(); + } + + entity = user.toVersia(); + break; + } + case "pub.versia:likes/Like": { + // Don't fetch a like of a note that is not public or unlisted + // prevents leaking the existence of a private note + const like = await Like.fromSql( + and( + eq(Likes.id, id), + sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`, + ), + ); + + if (!like) { + throw ApiError.likeNotFound(); + } + + const liker = await User.fromId(like.data.likerId); + + if (!liker || liker.remote) { + throw ApiError.accountNotFound(); + } + + entity = like.toVersia(); + break; + } + case "pub.versia:likes/Dislike": { + // Versia Server does not support dislikes + throw ApiError.notFound(); + } + case "pub.versia:shares/Share": { + const note = await Note.fromSql( + and( + eq(Notes.id, id), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + ); + + if ( + !(note && (await note.isViewableByUser(null))) || + note.remote || + !note.data.reblogId + ) { + throw ApiError.notFound(); + } + + entity = note.toVersiaShare(); + break; + } + case "pub.versia:reactions/Reaction": { + const reaction = await Reaction.fromId(id); + + if (!reaction) { + throw ApiError.notFound(); + } + + entity = reaction.toVersia(); + break; + } + } + + if (!entity) { + throw ApiError.notFound(); + } + + const { headers } = await Instance.sign( + entity, + new URL(context.req.url), + "GET", + ); + + return context.json(entity, 200, headers.toJSON()); + }, + ), +); diff --git a/packages/api/routes/inbox/index.test.ts b/packages/api/routes/versia/v0.6/inbox.test.ts similarity index 69% rename from packages/api/routes/inbox/index.test.ts rename to packages/api/routes/versia/v0.6/inbox.test.ts index 608c3c7e..b9c12a80 100644 --- a/packages/api/routes/inbox/index.test.ts +++ b/packages/api/routes/versia/v0.6/inbox.test.ts @@ -24,16 +24,13 @@ const userId = randomUUIDv7(); const shareId = randomUUIDv7(); const reactionId = randomUUIDv7(); const reaction2Id = randomUUIDv7(); -const userKeys = await User.generateKeys(); -const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(userKeys.private_key, "base64"), - "Ed25519", - false, - ["sign"], -); -const instanceKeys = await User.generateKeys(); -const inboxUrl = new URL("/inbox", config.http.base_url); + +const instanceKeys = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", +]); + +const inboxUrl = new URL("/.versia/v0.6/inbox", config.http.base_url); const { users, deleteUsers } = await getTestUsers(1); disableRealRequests(); @@ -48,24 +45,29 @@ mock(new URL("/.well-known/versia", instanceUrl).href, { name: "Versia", description: "Versia instance", created_at: new Date().toISOString(), - host: instanceUrl.hostname, + domain: instanceUrl.hostname, software: { name: "Versia", version: "1.0.0", }, compatibility: { extensions: [], - versions: ["0.5.0"], + versions: ["0.6.0"], }, public_key: { algorithm: "ed25519", - key: instanceKeys.public_key, + key: Buffer.from( + await crypto.subtle.exportKey( + "spki", + instanceKeys.publicKey, + ), + ).toString("base64"), }, }).toJSON(), }, }); -mock(new URL(`/users/${userId}`, instanceUrl).href, { +mock(new URL(`/.versia/v0.6/entities/User/${userId}`, instanceUrl).href, { response: { headers: { "Content-Type": "application/json", @@ -73,31 +75,18 @@ mock(new URL(`/users/${userId}`, instanceUrl).href, { data: new VersiaEntities.User({ id: userId, created_at: "2025-04-18T10:32:01.427Z", - uri: new URL(`/users/${userId}`, instanceUrl).href, type: "User", username: "testuser", - public_key: { - algorithm: "ed25519", - key: userKeys.public_key, - actor: new URL(`/users/${userId}`, instanceUrl).href, - }, - inbox: new URL(`/users/${userId}/inbox`, instanceUrl).href, - collections: { - featured: new URL(`/users/${userId}/featured`, instanceUrl) - .href, - followers: new URL(`/users/${userId}/followers`, instanceUrl) - .href, - following: new URL(`/users/${userId}/following`, instanceUrl) - .href, - outbox: new URL(`/users/${userId}/outbox`, instanceUrl).href, - }, + fields: [], + manually_approves_followers: false, + indexable: true, }).toJSON(), }, }); afterAll(async () => { // Delete the instance in database - const instance = await Instance.resolve(instanceUrl); + const instance = await Instance.resolve(instanceUrl.hostname); if (!instance) { throw new Error("Instance not found"); @@ -111,18 +100,18 @@ afterAll(async () => { describe("Inbox Tests", () => { test("should correctly process inbox request", async () => { - const exampleRequest = new VersiaEntities.Note({ + const exampleNote = new VersiaEntities.Note({ id: noteId, created_at: "2025-04-18T10:32:01.427Z", - uri: new URL(`/notes/${noteId}`, instanceUrl).href, type: "Note", extensions: { "pub.versia:custom_emojis": { emojis: [], }, }, + previews: [], attachments: [], - author: new URL(`/users/${userId}`, instanceUrl).href, + author: userId, content: { "text/html": { content: "

Hello!

", @@ -133,10 +122,6 @@ describe("Inbox Tests", () => { remote: false, }, }, - collections: { - replies: new URL(`/notes/${noteId}/replies`, instanceUrl).href, - quotes: new URL(`/notes/${noteId}/quotes`, instanceUrl).href, - }, group: "public", is_sensitive: false, mentions: [], @@ -146,16 +131,17 @@ describe("Inbox Tests", () => { }); const signedRequest = await sign( - privateKey, - new URL(exampleRequest.data.author), + instanceKeys.privateKey, + new URL(exampleNote.data.author), new Request(inboxUrl, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + "Content-Type": + "application/vnd.versia+json; charset=utf-8", + Accept: "application/vnd.versia+json", "User-Agent": "Versia/1.0.0", }, - body: JSON.stringify(exampleRequest.toJSON()), + body: JSON.stringify(exampleNote.toJSON()), }), ); @@ -170,7 +156,9 @@ describe("Inbox Tests", () => { await sleep(500); // Check if note was created in the database - const note = await Note.fromSql(eq(Notes.uri, exampleRequest.data.uri)); + const note = await Note.fromSql( + eq(Notes.remoteId, exampleNote.data.id), + ); expect(note).not.toBeNull(); }); @@ -179,20 +167,20 @@ describe("Inbox Tests", () => { const exampleRequest = new VersiaEntities.Share({ id: shareId, created_at: "2025-04-18T10:32:01.427Z", - uri: new URL(`/shares/${shareId}`, instanceUrl).href, type: "pub.versia:share/Share", - author: new URL(`/users/${userId}`, instanceUrl).href, - shared: new URL(`/notes/${noteId}`, instanceUrl).href, + author: userId, + shared: noteId, }); const signedRequest = await sign( - privateKey, + instanceKeys.privateKey, new URL(exampleRequest.data.author), new Request(inboxUrl, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + "Content-Type": + "application/vnd.versia+json; charset=utf-8", + Accept: "application/vnd.versia+json", "User-Agent": "Versia/1.0.0", }, body: JSON.stringify(exampleRequest.toJSON()), @@ -209,9 +197,7 @@ describe("Inbox Tests", () => { await sleep(500); - const dbNote = await Note.fromSql( - eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href), - ); + const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId)); if (!dbNote) { throw new Error("DBNote not found"); @@ -221,6 +207,7 @@ describe("Inbox Tests", () => { const share = await Note.fromSql( and( eq(Notes.reblogId, dbNote.id), + eq(Notes.remoteId, shareId), eq(Notes.authorId, dbNote.data.authorId), ), ); @@ -232,21 +219,21 @@ describe("Inbox Tests", () => { const exampleRequest = new VersiaEntities.Reaction({ id: reactionId, created_at: "2025-04-18T10:32:01.427Z", - uri: new URL(`/reactions/${reactionId}`, instanceUrl).href, type: "pub.versia:reactions/Reaction", - author: new URL(`/users/${userId}`, instanceUrl).href, - object: new URL(`/notes/${noteId}`, instanceUrl).href, + author: userId, + object: noteId, content: "👍", }); const signedRequest = await sign( - privateKey, + instanceKeys.privateKey, new URL(exampleRequest.data.author), new Request(inboxUrl, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + "Content-Type": + "application/vnd.versia+json; charset=utf-8", + Accept: "application/vnd.versia+json", "User-Agent": "Versia/1.0.0", }, body: JSON.stringify(exampleRequest.toJSON()), @@ -263,18 +250,14 @@ describe("Inbox Tests", () => { await sleep(500); - const dbNote = await Note.fromSql( - eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href), - ); + const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId)); if (!dbNote) { throw new Error("DBNote not found"); } // Find the remote user who reacted by URI - const remoteUser = await User.fromSql( - eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href), - ); + const remoteUser = await User.fromSql(eq(Users.remoteId, userId)); if (!remoteUser) { throw new Error("Remote user not found"); @@ -311,10 +294,9 @@ describe("Inbox Tests", () => { const exampleRequest = new VersiaEntities.Reaction({ id: reaction2Id, created_at: "2025-04-18T10:32:01.427Z", - uri: new URL(`/reactions/${reaction2Id}`, instanceUrl).href, type: "pub.versia:reactions/Reaction", - author: new URL(`/users/${userId}`, instanceUrl).href, - object: new URL(`/notes/${noteId}`, instanceUrl).href, + author: userId, + object: noteId, content: ":neocat:", extensions: { "pub.versia:custom_emojis": { @@ -323,9 +305,7 @@ describe("Inbox Tests", () => { name: ":neocat:", url: { "image/webp": { - hash: { - sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649", - }, + hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649", size: 4664, width: 256, height: 256, @@ -341,13 +321,14 @@ describe("Inbox Tests", () => { }); const signedRequest = await sign( - privateKey, + instanceKeys.privateKey, new URL(exampleRequest.data.author), new Request(inboxUrl, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + "Content-Type": + "application/vnd.versia+json; charset=utf-8", + Accept: "application/vnd.versia+json", "User-Agent": "Versia/1.0.0", }, body: JSON.stringify(exampleRequest.toJSON()), @@ -364,18 +345,14 @@ describe("Inbox Tests", () => { await sleep(500); - const dbNote = await Note.fromSql( - eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href), - ); + const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId)); if (!dbNote) { throw new Error("DBNote not found"); } // Find the remote user who reacted by URI - const remoteUser = await User.fromSql( - eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href), - ); + const remoteUser = await User.fromSql(eq(Users.remoteId, userId)); if (!remoteUser) { throw new Error("Remote user not found"); @@ -409,36 +386,29 @@ describe("Inbox Tests", () => { }); test("should correctly process Delete", async () => { - const deleteId = randomUUIDv7(); - // First check that the note exists in the database - const noteToDelete = await Note.fromSql( - eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href), - ); + const noteToDelete = await Note.fromSql(eq(Notes.remoteId, noteId)); expect(noteToDelete).not.toBeNull(); // Create a Delete request const exampleRequest = new VersiaEntities.Delete({ - id: deleteId, created_at: new Date().toISOString(), type: "Delete", - author: new URL(`/users/${userId}`, instanceUrl).href, + author: userId, deleted_type: "Note", - deleted: new URL(`/notes/${noteId}`, instanceUrl).href, + deleted: noteId, }); - // The author field is non-null in our test case, so we can safely assert it as a string - const authorUrl = exampleRequest.data.author as string; - const signedRequest = await sign( - privateKey, - new URL(authorUrl), + instanceKeys.privateKey, + new URL(exampleRequest.data.author), new Request(inboxUrl, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + "Content-Type": + "application/vnd.versia+json; charset=utf-8", + Accept: "application/vnd.versia+json", "User-Agent": "Versia/1.0.0", }, body: JSON.stringify(exampleRequest.toJSON()), @@ -456,9 +426,7 @@ describe("Inbox Tests", () => { await sleep(500); // Verify that the note was deleted from the database - const noteExists = await Note.fromSql( - eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href), - ); + const noteExists = await Note.fromSql(eq(Notes.remoteId, noteId)); expect(noteExists).toBeNull(); }); diff --git a/packages/api/routes/inbox/index.ts b/packages/api/routes/versia/v0.6/inbox.ts similarity index 78% rename from packages/api/routes/inbox/index.ts rename to packages/api/routes/versia/v0.6/inbox.ts index 98fd4182..c642b3d5 100644 --- a/packages/api/routes/inbox/index.ts +++ b/packages/api/routes/versia/v0.6/inbox.ts @@ -1,13 +1,13 @@ import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { InboxJobType, inboxQueue } from "@versia-server/kit/queues/inbox"; import { describeRoute, validator } from "hono-openapi"; -import { z } from "zod"; +import z from "zod"; +import { InboxJobType, inboxQueue } from "~/packages/kit/queues/inbox/queue"; export default apiRoute((app) => - app.post( - "/inbox", + app.get( + "/.versia/v0.6/inbox", describeRoute({ - summary: "Instance federation inbox", + summary: "Instance inbox endpoint", tags: ["Federation"], responses: { 200: { @@ -18,12 +18,9 @@ export default apiRoute((app) => validator( "header", z.object({ - "versia-signature": z.string().optional(), - "versia-signed-at": z.coerce.number().optional(), - "versia-signed-by": z - .url() - .or(z.string().startsWith("instance ")) - .optional(), + "versia-signature": z.string(), + "versia-signed-at": z.coerce.number(), + "versia-signed-by": z.string(), authorization: z.string().optional(), }), handleZodError, diff --git a/packages/api/routes/versia/v0.6/instance.ts b/packages/api/routes/versia/v0.6/instance.ts new file mode 100644 index 00000000..c5e9ae44 --- /dev/null +++ b/packages/api/routes/versia/v0.6/instance.ts @@ -0,0 +1,88 @@ +import { + type ImageContentFormatSchema, + InstanceMetadataSchema, +} from "@versia/sdk/schemas"; +import { config } from "@versia-server/config"; +import { apiRoute } from "@versia-server/kit/api"; +import { User } from "@versia-server/kit/db"; +import { Users } from "@versia-server/kit/tables"; +import { asc } from "drizzle-orm"; +import { describeRoute, resolver } from "hono-openapi"; +import type z from "zod"; +import { urlToContentFormat } from "@/content_types"; +import pkg from "../../../../../package.json" with { type: "json" }; + +export default apiRoute((app) => + app.get( + "/.versia/v0.6/instance", + describeRoute({ + summary: "Get instance metadata", + tags: ["Federation"], + responses: { + 200: { + description: "Instance metadata", + content: { + "application/json": { + schema: resolver(InstanceMetadataSchema), + }, + }, + }, + }, + }), + async (context) => { + // Get date of first user creation + const firstUser = await User.fromSql( + undefined, + asc(Users.createdAt), + ); + + const publicKey = Buffer.from( + await crypto.subtle.exportKey( + "spki", + config.instance.keys.public, + ), + ).toString("base64"); + + return context.json( + { + type: "InstanceMetadata" as const, + compatibility: { + extensions: [ + "pub.versia:custom_emojis", + "pub.versia:instance_messaging", + "pub.versia:likes", + "pub.versia:shares", + "pub.versia:reactions", + ], + versions: ["0.6.0"], + }, + domain: config.http.base_url.hostname, + name: config.instance.name, + description: config.instance.description, + public_key: { + key: publicKey, + algorithm: "ed25519" as const, + }, + software: { + name: "Versia Server", + version: pkg.version, + }, + banner: config.instance.branding.banner + ? (urlToContentFormat( + config.instance.branding.banner, + ) as z.infer) + : undefined, + logo: config.instance.branding.logo + ? (urlToContentFormat( + config.instance.branding.logo, + ) as z.infer) + : undefined, + created_at: + firstUser?.data.createdAt.toISOString() || + "1970-01-01T00:00:00Z", + } satisfies z.infer, + 200, + ); + }, + ), +); diff --git a/packages/api/routes/well-known/versia.ts b/packages/api/routes/well-known/versia.ts index ea221c96..e836ca82 100644 --- a/packages/api/routes/well-known/versia.ts +++ b/packages/api/routes/well-known/versia.ts @@ -1,87 +1,32 @@ -import { InstanceMetadataSchema } from "@versia/sdk/schemas"; -import { config } from "@versia-server/config"; import { apiRoute } from "@versia-server/kit/api"; -import { User } from "@versia-server/kit/db"; -import { Users } from "@versia-server/kit/tables"; -import { asc } from "drizzle-orm"; import { describeRoute, resolver } from "hono-openapi"; -import { urlToContentFormat } from "@/content_types"; -import pkg from "../../../../package.json" with { type: "json" }; +import z from "zod"; export default apiRoute((app) => app.get( "/.well-known/versia", describeRoute({ - summary: "Get instance metadata", + summary: "Get supported versia protocol versions", tags: ["Federation"], responses: { 200: { description: "Instance metadata", content: { "application/json": { - schema: resolver(InstanceMetadataSchema), + schema: resolver( + z.strictObject({ + versions: z.array(z.string().min(1)), + }), + ), }, }, }, }, }), - async (context) => { - // Get date of first user creation - const firstUser = await User.fromSql( - undefined, - asc(Users.createdAt), - ); - - const publicKey = Buffer.from( - await crypto.subtle.exportKey( - "spki", - config.instance.keys.public, - ), - ).toString("base64"); - + (context) => { return context.json( { - type: "InstanceMetadata" as const, - compatibility: { - extensions: [ - "pub.versia:custom_emojis", - "pub.versia:instance_messaging", - "pub.versia:likes", - "pub.versia:shares", - "pub.versia:reactions", - ], - versions: ["0.5.0"], - }, - host: config.http.base_url.host, - name: config.instance.name, - description: config.instance.description, - public_key: { - key: publicKey, - algorithm: "ed25519" as const, - }, - software: { - name: "Versia Server", - version: pkg.version, - }, - banner: config.instance.branding.banner - ? urlToContentFormat(config.instance.branding.banner) - : undefined, - logo: config.instance.branding.logo - ? urlToContentFormat(config.instance.branding.logo) - : undefined, - shared_inbox: new URL( - "/inbox", - config.http.base_url, - ).toString(), - created_at: firstUser?.data.createdAt.toISOString(), - extensions: { - "pub.versia:instance_messaging": { - endpoint: new URL( - "/messaging", - config.http.base_url, - ).toString(), - }, - }, + versions: ["0.6.0"], }, 200, ); diff --git a/packages/kit/db/instance.ts b/packages/kit/db/instance.ts index c064a5b0..a524c1c7 100644 --- a/packages/kit/db/instance.ts +++ b/packages/kit/db/instance.ts @@ -1,3 +1,4 @@ +import { sign } from "@versia/sdk/crypto"; import * as VersiaEntities from "@versia/sdk/entities"; import { FederationRequester } from "@versia/sdk/http"; import { config } from "@versia-server/config"; @@ -15,6 +16,7 @@ import { inArray, type SQL, } from "drizzle-orm"; +import type { HttpVerb, KnownEntity } from "~/types/api.ts"; import { ApiError } from "../api-error.ts"; import { db } from "../tables/db.ts"; import { Instances } from "../tables/schema.ts"; @@ -111,6 +113,13 @@ export class Instance extends BaseInterface { } } + public static get federationRequester(): FederationRequester { + return new FederationRequester( + config.instance.keys.private, + config.http.base_url, + ); + } + public static async fromUser(user: User): Promise { if (!user.data.instanceId) { return null; @@ -139,29 +148,24 @@ export class Instance extends BaseInterface { return this.data.id; } - public static async fetchMetadata(url: URL): Promise<{ + public static async fetchMetadata(domain: string): Promise<{ metadata: VersiaEntities.InstanceMetadata; protocol: "versia" | "activitypub"; }> { - const origin = new URL(url).origin; - const wellKnownUrl = new URL("/.well-known/versia", origin); - try { - const metadata = await new FederationRequester( - config.instance.keys.private, - config.http.base_url, - ).fetchEntity(wellKnownUrl, VersiaEntities.InstanceMetadata); + const metadata = + await Instance.federationRequester.resolveInstance(domain); return { metadata, protocol: "versia" }; } catch { // If the server doesn't have a Versia well-known endpoint, it's not a Versia instance // Try to resolve ActivityPub metadata instead - const data = await Instance.fetchActivityPubMetadata(url); + const data = await Instance.fetchActivityPubMetadata(domain); if (!data) { throw new ApiError( 404, - `Instance at ${origin} is not reachable or does not exist`, + `Instance at ${domain} is not reachable or does not exist`, ); } @@ -173,9 +177,9 @@ export class Instance extends BaseInterface { } private static async fetchActivityPubMetadata( - url: URL, + domain: string, ): Promise { - const origin = new URL(url).origin; + const origin = new URL(`https://${domain}`); const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); // Go to endpoint, then follow the links to the actual metadata @@ -254,7 +258,7 @@ export class Instance extends BaseInterface { key: "", algorithm: "ed25519", }, - host: new URL(url).host, + domain: origin.hostname, compatibility: { extensions: [], versions: [], @@ -268,50 +272,33 @@ export class Instance extends BaseInterface { } } - public static resolveFromHost(host: string): Promise { - if (host.startsWith("http")) { - const url = new URL(host); - - return Instance.resolve(url); - } - - const url = new URL(`https://${host}`); - - return Instance.resolve(url); - } - - public static async resolve(url: URL): Promise { - const host = url.host; - + public static async resolve(domain: string): Promise { const existingInstance = await Instance.fromSql( - eq(Instances.baseUrl, host), + eq(Instances.baseUrl, domain), ); if (existingInstance) { return existingInstance; } - const output = await Instance.fetchMetadata(url); + const output = await Instance.fetchMetadata(domain); const { metadata, protocol } = output; return Instance.insert({ id: randomUUIDv7(), - baseUrl: host, + baseUrl: domain, name: metadata.data.name, version: metadata.data.software.version, logo: metadata.data.logo, protocol, publicKey: metadata.data.public_key, - inbox: metadata.data.shared_inbox ?? null, extensions: metadata.data.extensions ?? null, }); } public async updateFromRemote(): Promise { - const output = await Instance.fetchMetadata( - new URL(`https://${this.data.baseUrl}`), - ); + const output = await Instance.fetchMetadata(this.data.baseUrl); if (!output) { federationResolversLogger.error`Failed to update instance ${chalk.bold( @@ -328,13 +315,39 @@ export class Instance extends BaseInterface { logo: metadata.data.logo, protocol, publicKey: metadata.data.public_key, - inbox: metadata.data.shared_inbox ?? null, extensions: metadata.data.extensions ?? null, }); return this; } + /** + * Signs a Versia entity with this instance's private key + * + * @param entity Entity to sign + * @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature) + * @param signatureMethod HTTP method to embed in signature (default: POST) + * @returns The signed string and headers to send with the request + */ + public static async sign( + entity: KnownEntity | VersiaEntities.Collection, + signatureUrl: URL, + signatureMethod: HttpVerb = "POST", + ): Promise<{ + headers: Headers; + }> { + const { headers } = await sign( + config.instance.keys.private, + config.http.base_url, + new Request(signatureUrl, { + method: signatureMethod, + body: JSON.stringify(entity), + }), + ); + + return { headers }; + } + public async sendMessage(content: string): Promise { if ( !this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint diff --git a/packages/kit/db/like.ts b/packages/kit/db/like.ts index 647415a0..ad6effa8 100644 --- a/packages/kit/db/like.ts +++ b/packages/kit/db/like.ts @@ -11,17 +11,22 @@ import { } from "drizzle-orm"; import { db } from "../tables/db.ts"; import { + type Instances, Likes, type Notes, Notifications, type Users, } from "../tables/schema.ts"; import { BaseInterface } from "./base.ts"; -import { User } from "./user.ts"; +import type { User } from "./user.ts"; type LikeType = InferSelectModel & { liker: InferSelectModel; - liked: InferSelectModel; + liked: InferSelectModel & { + author: InferSelectModel & { + instance: InferSelectModel | null; + }; + }; }; export class Like extends BaseInterface { @@ -57,7 +62,15 @@ export class Like extends BaseInterface { where: sql, orderBy, with: { - liked: true, + liked: { + with: { + author: { + with: { + instance: true, + }, + }, + }, + }, liker: true, }, }); @@ -65,6 +78,7 @@ export class Like extends BaseInterface { if (!found) { return null; } + return new Like(found); } @@ -73,7 +87,6 @@ export class Like extends BaseInterface { orderBy: SQL | undefined = desc(Likes.id), limit?: number, offset?: number, - extra?: Parameters[0], ): Promise { const found = await db.query.Likes.findMany({ where: sql, @@ -81,9 +94,16 @@ export class Like extends BaseInterface { limit, offset, with: { - liked: true, + liked: { + with: { + author: { + with: { + instance: true, + }, + }, + }, + }, liker: true, - ...extra?.with, }, }); @@ -146,37 +166,28 @@ export class Like extends BaseInterface { } public toVersia(): VersiaEntities.Like { + let likedReference = this.data.liked.id; + + if (this.data.liked.author.instance) { + likedReference = `${this.data.liked.author.instance.baseUrl}:${this.data.liked.remoteId}`; + } + return new VersiaEntities.Like({ - id: this.data.id, - author: User.getUri( - this.data.liker.id, - this.data.liker.uri ? new URL(this.data.liker.uri) : null, - ).href, + id: this.id, + author: this.data.liker.id, type: "pub.versia:likes/Like", created_at: this.data.createdAt.toISOString(), - liked: this.data.liked.uri - ? new URL(this.data.liked.uri).href - : new URL(`/notes/${this.data.liked.id}`, config.http.base_url) - .href, - uri: this.getUri().href, + liked: likedReference, }); } public unlikeToVersia(unliker?: User): VersiaEntities.Delete { return new VersiaEntities.Delete({ type: "Delete", - id: crypto.randomUUID(), created_at: new Date().toISOString(), - author: User.getUri( - unliker?.id ?? this.data.liker.id, - unliker?.data.uri - ? new URL(unliker.data.uri) - : this.data.liker.uri - ? new URL(this.data.liker.uri) - : null, - ).href, + author: unliker ? unliker.id : this.data.liker.id, deleted_type: "pub.versia:likes/Like", - deleted: this.getUri().href, + deleted: this.id, }); } } diff --git a/packages/kit/db/media.ts b/packages/kit/db/media.ts index b5f7e4b3..4c54a18d 100644 --- a/packages/kit/db/media.ts +++ b/packages/kit/db/media.ts @@ -476,9 +476,7 @@ export class Media extends BaseInterface { [file.type]: { content: uri.toString(), remote: true, - hash: { - sha256: hash, - }, + hash, width, height, description: options?.description, diff --git a/packages/kit/db/note.ts b/packages/kit/db/note.ts index 24b344ab..2c56f4c7 100644 --- a/packages/kit/db/note.ts +++ b/packages/kit/db/note.ts @@ -3,7 +3,6 @@ import type { Status as StatusSchema, } from "@versia/client/schemas"; import * as VersiaEntities from "@versia/sdk/entities"; -import { FederationRequester } from "@versia/sdk/http"; import type { NonTextContentFormatSchema } from "@versia/sdk/schemas"; import { config } from "@versia-server/config"; import { randomUUIDv7 } from "bun"; @@ -25,7 +24,6 @@ import { mergeAndDeduplicate } from "@/lib.ts"; import { sanitizedHtmlStrip } from "@/sanitization"; import { versiaTextToHtml } from "../parsers.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts"; -import { uuid } from "../regex.ts"; import { db } from "../tables/db.ts"; import { EmojiToNote, @@ -166,8 +164,24 @@ const findManyNotes = async ( : sql`false`.as("liked"), }, }, - reply: true, - quote: true, + reply: { + with: { + author: { + with: { + instance: true, + }, + }, + }, + }, + quote: { + with: { + author: { + with: { + instance: true, + }, + }, + }, + }, }, extras: { pinned: userId @@ -197,19 +211,13 @@ const findManyNotes = async ( return output.map((post) => ({ ...post, author: transformOutputToUserWithRelations(post.author), - mentions: post.mentions.map((mention) => ({ - ...mention.user, - endpoints: mention.user.endpoints, - })), + mentions: post.mentions.map((mention) => mention.user), attachments: post.attachments.map((attachment) => attachment.media), emojis: (post.emojis ?? []).map((emoji) => emoji.emoji), reblog: post.reblog && { ...post.reblog, author: transformOutputToUserWithRelations(post.reblog.author), - mentions: post.reblog.mentions.map((mention) => ({ - ...mention.user, - endpoints: mention.user.endpoints, - })), + mentions: post.reblog.mentions.map((mention) => mention.user), attachments: post.reblog.attachments.map( (attachment) => attachment.media, ), @@ -236,8 +244,20 @@ type NoteTypeWithRelations = NoteType & { attachments: (typeof Media.$type)[]; reblog: NoteTypeWithoutRecursiveRelations | null; emojis: (typeof Emoji.$type)[]; - reply: NoteType | null; - quote: NoteType | null; + reply: + | (NoteType & { + author: InferSelectModel & { + instance: typeof Instance.$type | null; + }; + }) + | null; + quote: + | (NoteType & { + author: InferSelectModel & { + instance: typeof Instance.$type | null; + }; + }) + | null; client: typeof Client.$type | null; pinned: boolean; reblogged: boolean; @@ -404,6 +424,17 @@ export class Note extends BaseInterface { return this.data.id; } + public get reference(): VersiaEntities.Reference { + if (this.remote) { + const instanceUrl = new URL( + this.author.data.instance?.baseUrl || "", + ); + return new VersiaEntities.Reference(this.id, instanceUrl.hostname); + } + + return new VersiaEntities.Reference(this.id); + } + public async federateToUsers(): Promise { const users = await this.getUsersToFederateTo(); @@ -489,13 +520,13 @@ export class Note extends BaseInterface { * If the note is already reblogged, it will return the existing reblog. Also creates a notification for the author of the note. * @param reblogger The user reblogging the note * @param visibility The visibility of the reblog - * @param uri The URI of the reblog, if it is remote + * @param remoteId The remote ID of the reblog, if it is from a remote user * @returns The reblog object created or the existing reblog */ public async reblog( reblogger: User, visibility: z.infer, - uri?: URL, + remoteId?: string, ): Promise { const existingReblog = await Note.fromSql( and(eq(Notes.authorId, reblogger.id), eq(Notes.reblogId, this.id)), @@ -515,7 +546,7 @@ export class Note extends BaseInterface { sensitive: false, updatedAt: new Date(), clientId: null, - uri: uri?.href, + remoteId, }); await this.recalculateReblogCount(); @@ -612,10 +643,10 @@ export class Note extends BaseInterface { * * If the note is already liked, it will return the existing like. Also creates a notification for the author of the note. * @param liker The user liking the note - * @param uri The URI of the like, if it is remote + * @param remoteId The id of the like, if it is remote * @returns The like object created or the existing like */ - public async like(liker: User, uri?: URL): Promise { + public async like(liker: User, remoteId?: string): Promise { // Check if the user has already liked the note const existingLike = await Like.fromSql( and(eq(Likes.likerId, liker.id), eq(Likes.likedId, this.id)), @@ -629,7 +660,7 @@ export class Note extends BaseInterface { id: randomUUIDv7(), likerId: liker.id, likedId: this.id, - uri: uri?.href, + remoteId, }); await this.recalculateLikeCount(); @@ -904,73 +935,84 @@ export class Note extends BaseInterface { } /** - * Resolve a note from a URI - * @param uri - The URI of the note to resolve + * Resolve a note from a reference + * @param reference - The URI of the note to resolve * @returns The resolved note */ - public static async resolve(uri: URL): Promise { + public static async resolve( + reference: VersiaEntities.Reference, + ): Promise { // Check if note not already in database - const foundNote = await Note.fromSql(eq(Notes.uri, uri.href)); + if ( + !reference.domain || + reference.domain === config.http.base_url.hostname + ) { + return await Note.fromId(reference.id); + } + + const instance = await Instance.resolve(reference.domain); + + if (!instance) { + return null; + } + + const foundNote = await Note.fromSql( + and( + eq(Notes.remoteId, reference.id), + eq( + Notes.authorId, + sql`( + SELECT "Users".id FROM "Users" + WHERE "Users".instanceId = ${instance.id} + LIMIT 1 + )`, + ), + ), + ); if (foundNote) { return foundNote; } - // Check if URI is of a local note - if (uri.origin === config.http.base_url.origin) { - const noteUuid = uri.pathname.match(uuid); - - if (!noteUuid?.[0]) { - throw new Error( - `URI ${uri} is of a local note, but it could not be parsed`, - ); - } - - return await Note.fromId(noteUuid[0]); - } - - return Note.fromVersia(uri); + return Note.fromVersia(reference); } /** * Takes a Versia Note representation, and serializes it to the database. * * If the note already exists, it will update it. - * @param versiaNote - URL or Versia Note representation + * @param versiaNote - Reference or Versia Note representation */ public static async fromVersia( - versiaNote: VersiaEntities.Note | URL, + versiaNote: VersiaEntities.Note | VersiaEntities.Reference, ): Promise { - if (versiaNote instanceof URL) { + if (versiaNote instanceof VersiaEntities.Reference) { // No bridge support for notes yet - const note = await new FederationRequester( - config.instance.keys.private, - config.http.base_url, - ).fetchEntity(versiaNote, VersiaEntities.Note); + const note = await Instance.federationRequester.fetchEntity( + versiaNote, + VersiaEntities.Note, + ); return Note.fromVersia(note); } - const { - author: authorUrl, - created_at, - uri, - extensions, - group, - is_sensitive, - mentions: noteMentions, - quotes, - replies_to, - subject, - } = versiaNote.data; - const instance = await Instance.resolve(new URL(authorUrl)); - const author = await User.resolve(new URL(authorUrl)); + const { created_at, extensions, group, id, is_sensitive, subject } = + versiaNote.data; + + if (!versiaNote.author.domain) { + throw new Error("Entity author domain is missing"); + } + + const instance = await Instance.resolve(versiaNote.author.domain); + const author = await User.resolve(versiaNote.author); if (!author) { throw new Error("Entity author could not be resolved"); } - const existingNote = await Note.fromSql(eq(Notes.uri, uri)); + const existingNote = await Note.fromSql( + and(eq(Notes.remoteId, id), eq(Notes.authorId, author.id)), + ); const note = existingNote ?? @@ -978,7 +1020,7 @@ export class Note extends BaseInterface { id: randomUUIDv7(), authorId: author.id, visibility: "public", - uri, + remoteId: id, createdAt: new Date(created_at), })); @@ -999,9 +1041,7 @@ export class Note extends BaseInterface { const mentions = ( await Promise.all( - noteMentions?.map((mention) => - User.resolve(new URL(mention)), - ) ?? [], + versiaNote.mentions.map((m) => User.resolve(m)) ?? [], ) ).filter((m) => m !== null); @@ -1011,10 +1051,12 @@ export class Note extends BaseInterface { ? "direct" : (group as "public" | "followers" | "unlisted"); - const reply = replies_to - ? await Note.resolve(new URL(replies_to)) + const reply = versiaNote.repliesTo + ? await Note.resolve(versiaNote.repliesTo) + : null; + const quote = versiaNote.quotes + ? await Note.resolve(versiaNote.quotes) : null; - const quote = quotes ? await Note.resolve(new URL(quotes)) : null; const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined; await note.update({ @@ -1169,9 +1211,11 @@ export class Note extends BaseInterface { mention.username, mention.instance?.baseUrl, ), - url: User.getUri( - mention.id, - mention.uri ? new URL(mention.uri) : null, + url: new URL( + `/@${mention.username}${ + mention.instance ? `@${mention.instance.baseUrl}` : "" + }`, + config.http.base_url, ).toString(), username: mention.username, })), @@ -1191,9 +1235,9 @@ export class Note extends BaseInterface { sensitive: data.sensitive, spoiler_text: data.spoilerText, tags: [], - uri: data.uri || this.getUri().toString(), + uri: this.getUri().toString(), visibility: data.visibility, - url: data.uri || this.getMastoUri().toString(), + url: this.getMastoUri().toString(), bookmarked: false, quote: data.quotingId ? ((await Note.fromId(data.quotingId, userFetching?.id).then( @@ -1207,9 +1251,14 @@ export class Note extends BaseInterface { } public getUri(): URL { - return this.data.uri - ? new URL(this.data.uri) - : new URL(`/notes/${this.id}`, config.http.base_url); + const domain = this.author.data.instance?.baseUrl + ? new URL(`https://${this.author.data.instance.baseUrl}`) + : config.http.base_url; + + return new URL( + `/.versia/v0.6/entities/Note/${this.id}`, + `https://${domain}`, + ); } /** @@ -1224,14 +1273,11 @@ export class Note extends BaseInterface { } public deleteToVersia(): VersiaEntities.Delete { - const id = crypto.randomUUID(); - return new VersiaEntities.Delete({ type: "Delete", - id, - author: this.author.uri.href, + author: this.author.id, deleted_type: "Note", - deleted: this.getUri().href, + deleted: this.id, created_at: new Date().toISOString(), }); } @@ -1242,12 +1288,24 @@ export class Note extends BaseInterface { */ public toVersia(): VersiaEntities.Note { const status = this.data; + + let quoteReference = status.quote?.id ?? null; + + if (quoteReference && status.quote?.author.instance) { + quoteReference = `${status.quote.author.instance.baseUrl}:${status.quote.remoteId}`; + } + + let replyReference = status.reply?.id ?? null; + + if (replyReference && status.reply?.author.instance) { + replyReference = `${status.reply.author.instance.baseUrl}:${status.reply.remoteId}`; + } + return new VersiaEntities.Note({ type: "Note", created_at: status.createdAt.toISOString(), id: status.id, - author: this.author.uri.href, - uri: this.getUri().href, + author: this.author.id, content: { "text/html": { content: status.content, @@ -1258,20 +1316,7 @@ export class Note extends BaseInterface { remote: false, }, }, - collections: { - replies: new URL( - `/notes/${status.id}/replies`, - config.http.base_url, - ).href, - quotes: new URL( - `/notes/${status.id}/quotes`, - config.http.base_url, - ).href, - "pub.versia:share/Shares": new URL( - `/notes/${status.id}/shares`, - config.http.base_url, - ).href, - }, + previews: [], attachments: status.attachments.map( (attachment) => new Media(attachment).toVersia().data as z.infer< @@ -1279,25 +1324,13 @@ export class Note extends BaseInterface { >, ), is_sensitive: status.sensitive, - mentions: status.mentions.map( - (mention) => - User.getUri( - mention.id, - mention.uri ? new URL(mention.uri) : null, - ).href, + mentions: status.mentions.map((mention) => + mention.instance + ? `${mention.instance.baseUrl}:${mention.id}` + : mention.id, ), - quotes: status.quote - ? status.quote.uri - ? new URL(status.quote.uri).href - : new URL(`/notes/${status.quote.id}`, config.http.base_url) - .href - : null, - replies_to: status.reply - ? status.reply.uri - ? new URL(status.reply.uri).href - : new URL(`/notes/${status.reply.id}`, config.http.base_url) - .href - : null, + quotes: quoteReference, + replies_to: replyReference, subject: status.spoilerText, // TODO: Refactor as part of groups group: status.visibility === "public" ? "public" : "followers", @@ -1319,26 +1352,22 @@ export class Note extends BaseInterface { return new VersiaEntities.Share({ type: "pub.versia:share/Share", - id: crypto.randomUUID(), - author: this.author.uri.href, - uri: new URL(`/shares/${this.id}`, config.http.base_url).href, + author: this.author.id, + id: this.id, created_at: new Date().toISOString(), - shared: new Note(this.data.reblog as NoteTypeWithRelations).getUri() - .href, + shared: this.data.reblog.author.instance + ? `${this.data.reblog.author.instance.baseUrl}:${this.data.reblog.id}` + : this.data.reblog.id, }); } public toVersiaUnshare(): VersiaEntities.Delete { return new VersiaEntities.Delete({ type: "Delete", - id: crypto.randomUUID(), created_at: new Date().toISOString(), - author: User.getUri( - this.data.authorId, - this.data.author.uri ? new URL(this.data.author.uri) : null, - ).href, + author: this.author.id, deleted_type: "pub.versia:share/Share", - deleted: new URL(`/shares/${this.id}`, config.http.base_url).href, + deleted: this.id, }); } diff --git a/packages/kit/db/reaction.ts b/packages/kit/db/reaction.ts index d193371c..662ae967 100644 --- a/packages/kit/db/reaction.ts +++ b/packages/kit/db/reaction.ts @@ -1,5 +1,4 @@ import * as VersiaEntities from "@versia/sdk/entities"; -import { config } from "@versia-server/config"; import { randomUUIDv7 } from "bun"; import { and, @@ -12,17 +11,26 @@ import { type SQL, } from "drizzle-orm"; import { db } from "../tables/db.ts"; -import { type Notes, Reactions, type Users } from "../tables/schema.ts"; +import { + type Instances, + type Notes, + Reactions, + type Users, +} from "../tables/schema.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; import type { Note } from "./note.ts"; -import { User } from "./user.ts"; +import type { User } from "./user.ts"; type ReactionType = InferSelectModel & { emoji: typeof Emoji.$type | null; author: InferSelectModel; - note: InferSelectModel; + note: InferSelectModel & { + author: InferSelectModel & { + instance: InferSelectModel | null; + }; + }; }; export class Reaction extends BaseInterface { @@ -64,7 +72,15 @@ export class Reaction extends BaseInterface { }, }, author: true, - note: true, + note: { + with: { + author: { + with: { + instance: true, + }, + }, + }, + }, }, orderBy, }); @@ -96,7 +112,15 @@ export class Reaction extends BaseInterface { }, }, author: true, - note: true, + note: { + with: { + author: { + with: { + instance: true, + }, + }, + }, + }, }, }); @@ -210,19 +234,18 @@ export class Reaction extends BaseInterface { throw new Error("Cannot convert a non-local reaction to Versia"); } + let noteReference = this.data.note.id; + + if (this.data.note.author.instance) { + noteReference = `${this.data.note.author.instance.baseUrl}:${this.data.note.remoteId}`; + } + return new VersiaEntities.Reaction({ - uri: this.getUri(config.http.base_url).href, type: "pub.versia:reactions/Reaction", - author: User.getUri( - this.data.authorId, - this.data.author.uri ? new URL(this.data.author.uri) : null, - ).href, + author: this.data.author.id, created_at: this.data.createdAt.toISOString(), id: this.id, - object: this.data.note.uri - ? new URL(this.data.note.uri).href - : new URL(`/notes/${this.data.noteId}`, config.http.base_url) - .href, + object: noteReference, content: this.hasCustomEmoji() ? `:${this.data.emoji?.shortcode}:` : this.data.emojiText || "", @@ -243,14 +266,10 @@ export class Reaction extends BaseInterface { public toVersiaUnreact(): VersiaEntities.Delete { return new VersiaEntities.Delete({ type: "Delete", - id: crypto.randomUUID(), created_at: new Date().toISOString(), - author: User.getUri( - this.data.authorId, - this.data.author.uri ? new URL(this.data.author.uri) : null, - ).href, + author: this.data.authorId, deleted_type: "pub.versia:reactions/Reaction", - deleted: this.getUri(config.http.base_url).href, + deleted: this.id, }); } @@ -279,7 +298,7 @@ export class Reaction extends BaseInterface { return Reaction.insert({ id: randomUUIDv7(), - uri: reactionToConvert.data.uri, + remoteId: reactionToConvert.data.id, authorId: author.id, noteId: note.id, emojiId: emoji ? emoji.id : null, diff --git a/packages/kit/db/user.ts b/packages/kit/db/user.ts index 7d5ef546..4d63a931 100644 --- a/packages/kit/db/user.ts +++ b/packages/kit/db/user.ts @@ -4,15 +4,11 @@ import type { RolePermission, Source, } from "@versia/client/schemas"; -import { sign } from "@versia/sdk/crypto"; import * as VersiaEntities from "@versia/sdk/entities"; import { FederationRequester } from "@versia/sdk/http"; import type { ImageContentFormatSchema } from "@versia/sdk/schemas"; import { config, ProxiableUrl } from "@versia-server/config"; -import { - federationDeliveryLogger, - federationResolversLogger, -} from "@versia-server/logging"; +import { federationDeliveryLogger } from "@versia-server/logging"; import { password as bunPassword, randomUUIDv7 } from "bun"; import chalk from "chalk"; import { @@ -33,10 +29,9 @@ import { htmlToText } from "html-to-text"; import type { z } from "zod"; import { getBestContentType } from "@/content_types"; import { randomString } from "@/math"; -import type { HttpVerb, KnownEntity } from "~/types/api.ts"; +import type { KnownEntity } from "~/types/api.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts"; import { PushJobType, pushQueue } from "../queues/push/queue.ts"; -import { uuid } from "../regex.ts"; import { db } from "../tables/db.ts"; import { EmojiToUser, @@ -79,7 +74,7 @@ export const userRelations = { // TODO: Remove this function and use what drizzle outputs directly instead of transforming it export const transformOutputToUserWithRelations = ( - user: Omit, "endpoints"> & { + user: InferSelectModel & { followerCount: unknown; followingCount: unknown; statusCount: unknown; @@ -96,7 +91,6 @@ export const transformOutputToUserWithRelations = ( roleId: string; role?: typeof Role.$type; }[]; - endpoints: unknown; }, ): typeof User.$type => { return { @@ -104,17 +98,6 @@ export const transformOutputToUserWithRelations = ( followerCount: Number(user.followerCount), followingCount: Number(user.followingCount), statusCount: Number(user.statusCount), - endpoints: - user.endpoints ?? - ({} as Partial<{ - dislikes: string; - featured: string; - likes: string; - followers: string; - following: string; - inbox: string; - outbox: string; - }>), emojis: user.emojis.map( (emoji) => (emoji as unknown as Record) @@ -239,14 +222,26 @@ export class User extends BaseInterface { return !this.local; } - public get uri(): URL { - return this.data.uri - ? new URL(this.data.uri) - : new URL(`/users/${this.data.id}`, config.http.base_url); + public get reference(): VersiaEntities.Reference { + if (this.local) { + return new VersiaEntities.Reference(this.id); + } + + return new VersiaEntities.Reference( + this.data.remoteId as string, + (this.data.instance as typeof Instance.$type).baseUrl, + ); } - public static getUri(id: string, uri: URL | null): URL { - return uri ? uri : new URL(`/users/${id}`, config.http.base_url); + public get uri(): URL { + const domain = this.data.instance?.baseUrl + ? new URL(`https://${this.data.instance.baseUrl}`) + : config.http.base_url; + + return new URL( + `/.versia/v0.6/entities/User/${this.id}`, + `https://${domain}`, + ); } public hasPermission(permission: RolePermission): boolean { @@ -335,13 +330,13 @@ export class User extends BaseInterface { } private unfollowToVersia(followee: User): VersiaEntities.Unfollow { - const id = crypto.randomUUID(); return new VersiaEntities.Unfollow({ type: "Unfollow", - id, - author: this.uri.href, + author: this.id, created_at: new Date().toISOString(), - followee: followee.uri.href, + followee: followee.data.instance + ? `${followee.data.instance.baseUrl}:${followee.id}` + : followee.id, }); } @@ -359,10 +354,11 @@ export class User extends BaseInterface { const entity = new VersiaEntities.FollowAccept({ type: "FollowAccept", - id: crypto.randomUUID(), - author: this.uri.href, + author: this.id, created_at: new Date().toISOString(), - follower: follower.uri.href, + follower: follower.data.instance + ? `${follower.data.instance.baseUrl}:${follower.id}` + : follower.id, }); await deliveryQueue.add(DeliveryJobType.FederateEntity, { @@ -383,10 +379,11 @@ export class User extends BaseInterface { const entity = new VersiaEntities.FollowReject({ type: "FollowReject", - id: crypto.randomUUID(), - author: this.uri.href, + author: this.id, created_at: new Date().toISOString(), - follower: follower.uri.href, + follower: follower.data.instance + ? `${follower.data.instance.baseUrl}:${follower.id}` + : follower.id, }); await deliveryQueue.add(DeliveryJobType.FederateEntity, { @@ -396,41 +393,6 @@ export class User extends BaseInterface { }); } - /** - * Signs a Versia entity with that user's private key - * - * @param entity Entity to sign - * @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature) - * @param signatureMethod HTTP method to embed in signature (default: POST) - * @returns The signed string and headers to send with the request - */ - public async sign( - entity: KnownEntity | VersiaEntities.Collection, - signatureUrl: URL, - signatureMethod: HttpVerb = "POST", - ): Promise<{ - headers: Headers; - }> { - const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(this.data.privateKey ?? "", "base64"), - "Ed25519", - false, - ["sign"], - ); - - const { headers } = await sign( - privateKey, - this.uri, - new Request(signatureUrl, { - method: signatureMethod, - body: JSON.stringify(entity), - }), - ); - - return { headers }; - } - /** * Perform a WebFinger lookup to find a user's URI * @param username @@ -708,54 +670,41 @@ export class User extends BaseInterface { * Takes a Versia User representation, and serializes it to the database. * * If the user already exists, it will update it. - * @param versiaUser URL or Versia User representation + * @param versiaUser Reference or Versia User representation */ public static async fromVersia( - versiaUser: VersiaEntities.User | URL, + versiaUser: VersiaEntities.User | VersiaEntities.Reference, + domain: string, ): Promise { - if (versiaUser instanceof URL) { - let uri = versiaUser; - const instance = await Instance.resolve(uri); - - if (instance.data.protocol === "activitypub") { - if (!config.federation.bridge) { - throw new Error("ActivityPub bridge is not enabled"); - } - - uri = new URL( - `/apbridge/versia/query?${new URLSearchParams({ - user_url: uri.href, - })}`, - config.federation.bridge.url, + if (versiaUser instanceof VersiaEntities.Reference) { + if (!versiaUser.domain) { + throw new Error( + "Cannot fetch Versia user from reference without domain", ); } - const user = await new FederationRequester( - config.instance.keys.private, - config.http.base_url, - ).fetchEntity(uri, VersiaEntities.User); + const user = await Instance.federationRequester.fetchEntity( + versiaUser, + VersiaEntities.User, + ); - return User.fromVersia(user); + return User.fromVersia(user, versiaUser.domain); } const { username, - inbox, - avatar, - header, display_name, + id, fields, - collections, created_at, manually_approves_followers, bio, - public_key, - uri, extensions, } = versiaUser.data; - const instance = await Instance.resolve(new URL(versiaUser.data.uri)); + + const instance = await Instance.resolve(domain); const existingUser = await User.fromSql( - eq(Users.uri, versiaUser.data.uri), + and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)), ); const user = @@ -763,60 +712,50 @@ export class User extends BaseInterface { (await User.insert({ username, id: randomUUIDv7(), - publicKey: public_key.key, - uri, instanceId: instance.id, + remoteId: id, })); // Avatars and headers are stored in a separate table, so we need to update them separately let userAvatar: Media | null = null; let userHeader: Media | null = null; - if (avatar) { + if (versiaUser.avatar) { if (user.avatar) { userAvatar = new Media( await user.avatar.update({ - content: avatar, + content: versiaUser.avatar.data, }), ); } else { userAvatar = await Media.insert({ id: randomUUIDv7(), - content: avatar, + content: versiaUser.avatar.data, }); } } - if (header) { + if (versiaUser.header) { if (user.header) { userHeader = new Media( await user.header.update({ - content: header, + content: versiaUser.header.data, }), ); } else { userHeader = await Media.insert({ id: randomUUIDv7(), - content: header, + content: versiaUser.header.data, }); } } await user.update({ createdAt: new Date(created_at), - endpoints: { - inbox, - outbox: collections.outbox, - followers: collections.followers, - following: collections.following, - featured: collections.featured, - likes: collections["pub.versia:likes/Likes"] ?? undefined, - dislikes: collections["pub.versia:likes/Dislikes"] ?? undefined, - }, - isLocked: manually_approves_followers ?? false, + isLocked: manually_approves_followers, avatarId: userAvatar?.id, headerId: userHeader?.id, - fields: fields ?? [], + fields, displayName: display_name, note: getBestContentType(bio).content, }); @@ -847,31 +786,39 @@ export class User extends BaseInterface { return user; } - public static async resolve(uri: URL): Promise { - federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`; + public static async resolve( + reference: VersiaEntities.Reference, + ): Promise { // Check if user not already in database - const foundUser = await User.fromSql(eq(Users.uri, uri.href)); + if ( + !reference.domain || + reference.domain === config.http.base_url.hostname + ) { + const user = await User.fromId(reference.id); + + if (!user) { + throw new Error( + "Failed to resolve user reference: User not found", + ); + } + + return user; + } + + const instance = await Instance.resolve(reference.domain); + + const foundUser = await User.fromSql( + and( + eq(Users.instanceId, instance.id), + eq(Users.remoteId, reference.id), + ), + ); if (foundUser) { return foundUser; } - // Check if URI is of a local user - if (uri.origin === config.http.base_url.origin) { - const userUuid = uri.href.match(uuid); - - if (!userUuid?.[0]) { - throw new Error( - `URI ${uri} is of a local user, but it could not be parsed`, - ); - } - - return await User.fromId(userUuid[0]); - } - - federationResolversLogger.debug`User not found in database, fetching from remote`; - - return User.fromVersia(uri); + return User.fromVersia(reference, reference.domain); } /** @@ -890,31 +837,6 @@ export class User extends BaseInterface { return this.avatar?.getUrl(); } - public static async generateKeys(): Promise<{ - private_key: string; - public_key: string; - }> { - const keys = await crypto.subtle.generateKey("Ed25519", true, [ - "sign", - "verify", - ]); - - const privateKey = Buffer.from( - await crypto.subtle.exportKey("pkcs8", keys.privateKey), - ).toString("base64"); - - const publicKey = Buffer.from( - await crypto.subtle.exportKey("spki", keys.publicKey), - ).toString("base64"); - - // Add header, footer and newlines later on - // These keys are base64 encrypted - return { - private_key: privateKey, - public_key: publicKey, - }; - } - public static async register( username: string, options?: Partial<{ @@ -924,8 +846,6 @@ export class User extends BaseInterface { isAdmin: boolean; }>, ): Promise { - const keys = await User.generateKeys(); - const user = await User.insert({ id: randomUUIDv7(), username, @@ -937,9 +857,7 @@ export class User extends BaseInterface { note: "", avatarId: options?.avatar?.id, isAdmin: options?.isAdmin, - publicKey: keys.public_key, fields: [], - privateKey: keys.private_key, updatedAt: new Date(), source: { language: "en", @@ -999,11 +917,9 @@ export class User extends BaseInterface { newUser.avatar || newUser.header || newUser.fields || - newUser.publicKey || newUser.isAdmin || newUser.isBot || newUser.isLocked || - newUser.endpoints || newUser.isDiscoverable || newUser.isIndexable) ) { @@ -1013,20 +929,6 @@ export class User extends BaseInterface { return updated.data; } - public get federationRequester(): Promise { - return crypto.subtle - .importKey( - "pkcs8", - Buffer.from(this.data.privateKey ?? "", "base64"), - "Ed25519", - false, - ["sign"], - ) - .then((k) => { - return new FederationRequester(k, this.uri); - }); - } - /** * Get all remote followers of the user * @returns The remote followers @@ -1076,17 +978,13 @@ export class User extends BaseInterface { entity: KnownEntity, user: User, ): Promise<{ ok: boolean }> { - const inbox = user.data.instance?.inbox || user.data.endpoints?.inbox; - - if (!inbox) { - throw new Error( - `User ${chalk.gray(user.uri)} does not have an inbox endpoint`, - ); + if (!user.data.instance) { + throw new Error("Cannot federate to a local user"); } try { - await (await this.federationRequester).postEntity( - new URL(inbox), + await Instance.federationRequester.postEntity( + user.data.instance.baseUrl, entity, ); } catch (e) { @@ -1110,9 +1008,12 @@ export class User extends BaseInterface { display_name: user.displayName || user.username, note: user.note, uri: this.uri.href, - url: - user.uri || - new URL(`/@${user.username}`, config.http.base_url).href, + url: new URL( + `/@${user.username}${ + user.instanceId ? `@${user.instance?.baseUrl}` : "" + }`, + config.http.base_url, + ).href, avatar: this.getAvatarUrl().proxied, header: this.getHeaderUrl()?.proxied ?? "", locked: user.isLocked, @@ -1166,7 +1067,6 @@ export class User extends BaseInterface { return new VersiaEntities.User({ id: user.id, type: "User", - uri: this.uri.href, bio: { "text/html": { content: user.note, @@ -1178,34 +1078,6 @@ export class User extends BaseInterface { }, }, created_at: user.createdAt.toISOString(), - collections: { - featured: new URL( - `/users/${user.id}/featured`, - config.http.base_url, - ).href, - "pub.versia:likes/Likes": new URL( - `/users/${user.id}/likes`, - config.http.base_url, - ).href, - "pub.versia:likes/Dislikes": new URL( - `/users/${user.id}/dislikes`, - config.http.base_url, - ).href, - followers: new URL( - `/users/${user.id}/followers`, - config.http.base_url, - ).href, - following: new URL( - `/users/${user.id}/following`, - config.http.base_url, - ).href, - outbox: new URL( - `/users/${user.id}/outbox`, - config.http.base_url, - ).href, - }, - inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url) - .href, indexable: this.data.isIndexable, username: user.username, manually_approves_followers: this.data.isLocked, @@ -1217,11 +1089,6 @@ export class User extends BaseInterface { >, display_name: user.displayName, fields: user.fields, - public_key: { - actor: new URL(`/users/${user.id}`, config.http.base_url).href, - key: user.publicKey, - algorithm: "ed25519", - }, extensions: { "pub.versia:custom_emojis": { emojis: user.emojis.map((emoji) => diff --git a/packages/kit/inbox-processor.ts b/packages/kit/inbox-processor.ts index f7c4a631..7e704c88 100644 --- a/packages/kit/inbox-processor.ts +++ b/packages/kit/inbox-processor.ts @@ -199,7 +199,12 @@ export class InboxProcessor { .on(VersiaEntities.Delete, (d) => InboxProcessor.processDelete(d), ) - .on(VersiaEntities.User, (u) => InboxProcessor.processUser(u)) + .on(VersiaEntities.User, (u) => + InboxProcessor.processUser( + u, + this.sender?.instance.data.baseUrl ?? "", + ), + ) .on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s)) .on(VersiaEntities.Reaction, (r) => InboxProcessor.processReaction(r), @@ -221,8 +226,8 @@ export class InboxProcessor { private static async processReaction( reaction: VersiaEntities.Reaction, ): Promise { - const author = await User.resolve(new URL(reaction.data.author)); - const note = await Note.resolve(new URL(reaction.data.object)); + const author = await User.resolve(reaction.author); + const note = await Note.resolve(reaction.object); if (!author) { throw new ApiError(404, "Author not found"); @@ -264,9 +269,13 @@ export class InboxProcessor { * Handles User entity processing. * * @param {VersiaUser} user - The User entity to process. + * @param {string} domain - The domain of the user. * @returns {Promise} */ - private static async processUser(user: VersiaEntities.User): Promise { + private static async processUser( + user: VersiaEntities.User, + domain: string, + ): Promise { if ( config.validation.filters.username.some((filter) => filter.test(user.data.username), @@ -294,7 +303,7 @@ export class InboxProcessor { return; } - await User.fromVersia(user); + await User.fromVersia(user, domain); } /** @@ -306,8 +315,8 @@ export class InboxProcessor { private static async processFollowRequest( follow: VersiaEntities.Follow, ): Promise { - const author = await User.resolve(new URL(follow.data.author)); - const followee = await User.resolve(new URL(follow.data.followee)); + const author = await User.resolve(follow.author); + const followee = await User.resolve(follow.followee); if (!author) { throw new ApiError(404, "Author not found"); @@ -354,10 +363,8 @@ export class InboxProcessor { private static async processFollowAccept( followAccept: VersiaEntities.FollowAccept, ): Promise { - const author = await User.resolve(new URL(followAccept.data.author)); - const follower = await User.resolve( - new URL(followAccept.data.follower), - ); + const author = await User.resolve(followAccept.author); + const follower = await User.resolve(followAccept.follower); if (!author) { throw new ApiError(404, "Author not found"); @@ -391,10 +398,8 @@ export class InboxProcessor { private static async processFollowReject( followReject: VersiaEntities.FollowReject, ): Promise { - const author = await User.resolve(new URL(followReject.data.author)); - const follower = await User.resolve( - new URL(followReject.data.follower), - ); + const author = await User.resolve(followReject.author); + const follower = await User.resolve(followReject.follower); if (!author) { throw new ApiError(404, "Author not found"); @@ -428,8 +433,8 @@ export class InboxProcessor { private static async processShare( share: VersiaEntities.Share, ): Promise { - const author = await User.resolve(new URL(share.data.author)); - const sharedNote = await Note.resolve(new URL(share.data.shared)); + const author = await User.resolve(share.author); + const sharedNote = await Note.resolve(share.shared); if (!author) { throw new ApiError(404, "Author not found"); @@ -439,7 +444,7 @@ export class InboxProcessor { throw new ApiError(404, "Shared Note not found"); } - await sharedNote.reblog(author, "public", new URL(share.data.uri)); + await sharedNote.reblog(author, "public", share.data.id); } /** @@ -451,17 +456,15 @@ export class InboxProcessor { public static async processDelete( delete_: VersiaEntities.Delete, ): Promise { - const toDelete = delete_.data.deleted; + const toDelete = delete_.deleted; - const author = delete_.data.author - ? await User.resolve(new URL(delete_.data.author)) - : null; + const author = await User.resolve(delete_.author); switch (delete_.data.deleted_type) { case "Note": { const note = await Note.fromSql( - eq(Notes.uri, toDelete), - author ? eq(Notes.authorId, author.id) : undefined, + eq(Notes.remoteId, toDelete.id), + eq(Notes.authorId, author.id), ); if (!note) { @@ -475,7 +478,7 @@ export class InboxProcessor { return; } case "User": { - const userToDelete = await User.resolve(new URL(toDelete)); + const userToDelete = await User.resolve(toDelete); if (!userToDelete) { throw new ApiError(404, "User to delete not found"); @@ -490,8 +493,8 @@ export class InboxProcessor { } case "pub.versia:likes/Like": { const like = await Like.fromSql( - eq(Likes.uri, toDelete), - author ? eq(Likes.likerId, author.id) : undefined, + eq(Likes.remoteId, toDelete.id), + eq(Likes.likerId, author.id), ); if (!like) { @@ -525,7 +528,10 @@ export class InboxProcessor { } const reblog = await Note.fromSql( - and(eq(Notes.uri, toDelete), eq(Notes.authorId, author.id)), + and( + eq(Notes.remoteId, toDelete.id), + eq(Notes.authorId, author.id), + ), ); if (!reblog) { @@ -568,8 +574,8 @@ export class InboxProcessor { private static async processLikeRequest( like: VersiaEntities.Like, ): Promise { - const author = await User.resolve(new URL(like.data.author)); - const likedNote = await Note.resolve(new URL(like.data.liked)); + const author = await User.resolve(like.author); + const likedNote = await Note.resolve(like.liked); if (!author) { throw new ApiError(404, "Author not found"); @@ -579,7 +585,7 @@ export class InboxProcessor { throw new ApiError(404, "Liked Note not found"); } - await likedNote.like(author, new URL(like.data.uri)); + await likedNote.like(author, like.data.id); } /** diff --git a/packages/kit/parsers.ts b/packages/kit/parsers.ts index 5d0e66ac..c02e0a21 100644 --- a/packages/kit/parsers.ts +++ b/packages/kit/parsers.ts @@ -1,4 +1,4 @@ -import type * as VersiaEntities from "@versia/sdk/entities"; +import * as VersiaEntities from "@versia/sdk/entities"; import { FederationRequester } from "@versia/sdk/http"; import { config } from "@versia-server/config"; import { and, eq, inArray, isNull, or } from "drizzle-orm"; @@ -13,6 +13,7 @@ import { letter, } from "magic-regexp"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; +import { Instance } from "./db/instance.ts"; import { User } from "./db/user.ts"; import { markdownToHtml } from "./markdown.ts"; import { mention } from "./regex.ts"; @@ -81,7 +82,12 @@ export const parseMentionsFromText = async (text: string): Promise => { ); if (url) { - const user = await User.resolve(url); + const userEntity = await Instance.federationRequester.fetchSigned( + url, + VersiaEntities.User, + ); + + const user = await User.fromVersia(userEntity, url.hostname); if (user) { finalList.push(user); diff --git a/packages/kit/queues/fetch/worker.ts b/packages/kit/queues/fetch/worker.ts index 90ddfbff..c463fe73 100644 --- a/packages/kit/queues/fetch/worker.ts +++ b/packages/kit/queues/fetch/worker.ts @@ -17,7 +17,7 @@ export const getFetchWorker = (): Worker => await job.log(`Fetching instance metadata from [${uri}]`); // Check if exists - const host = new URL(uri).host; + const host = new URL(uri).hostname; const existingInstance = await Instance.fromSql( eq(Instances.baseUrl, host), @@ -37,7 +37,7 @@ export const getFetchWorker = (): Worker => return; } - await Instance.resolve(new URL(uri)); + await Instance.resolve(host); await job.log( `✔ Finished fetching instance metadata from [${uri}]`, diff --git a/packages/kit/queues/inbox/worker.ts b/packages/kit/queues/inbox/worker.ts index 95220d92..11848be1 100644 --- a/packages/kit/queues/inbox/worker.ts +++ b/packages/kit/queues/inbox/worker.ts @@ -2,7 +2,6 @@ import { config } from "@versia-server/config"; import { Worker } from "bullmq"; import { ApiError } from "../../api-error.ts"; import { Instance } from "../../db/instance.ts"; -import { User } from "../../db/user.ts"; import { InboxProcessor } from "../../inbox-processor.ts"; import { connection } from "../../redis.ts"; import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts"; @@ -72,51 +71,29 @@ export const getInboxWorker = (): Worker => "versia-signed-by": string; }; - const sender = await User.resolve(new URL(signedBy)); + const sender = await Instance.resolve(signedBy); - if (!(sender || signedBy.startsWith("instance "))) { + if (!sender) { await job.log( - `Could not resolve sender URI [${signedBy}]`, + `Could not resolve sender domain [${signedBy}]`, ); return; } - if (sender?.local) { - throw new Error( - "Cannot process federation requests from local users", - ); - } - - const remoteInstance = sender - ? await Instance.fromUser(sender) - : await Instance.resolveFromHost( - signedBy.split(" ")[1], - ); - - if (!remoteInstance) { - await job.log("Could not resolve the remote instance."); - - return; - } - await job.log( - `Entity [${data.id}] is from remote instance [${remoteInstance.data.baseUrl}]`, + `Entity [${data.id}] is from remote instance [${sender.data.baseUrl}]`, ); - if (!remoteInstance.data.publicKey?.key) { + if (!sender.data.publicKey?.key) { throw new Error( - `Instance ${remoteInstance.data.baseUrl} has no public key stored in database`, + `Instance ${sender.data.baseUrl} has no public key stored in database`, ); } const key = await crypto.subtle.importKey( "spki", - Buffer.from( - sender?.data.publicKey ?? - remoteInstance.data.publicKey.key, - "base64", - ), + Buffer.from(sender.data.publicKey.key, "base64"), "Ed25519", false, ["verify"], @@ -127,7 +104,7 @@ export const getInboxWorker = (): Worker => req, data, { - instance: remoteInstance, + instance: sender, key, }, undefined, @@ -147,10 +124,10 @@ export const getInboxWorker = (): Worker => ); await job.log( - `Sending error message to instance [${remoteInstance.data.baseUrl}]`, + `Sending error message to instance [${sender.data.baseUrl}]`, ); - await remoteInstance.sendMessage( + await sender.sendMessage( `Failed processing entity [${ data.uri }] delivered to inbox. Returned error:\n\n${JSON.stringify( diff --git a/packages/kit/tables/schema.ts b/packages/kit/tables/schema.ts index f379c579..53a2d4d9 100644 --- a/packages/kit/tables/schema.ts +++ b/packages/kit/tables/schema.ts @@ -137,6 +137,7 @@ export const PushSubscriptionsRelations = relations( export const Reactions = pgTable("Reaction", { id: id(), uri: uri(), + remoteId: text("remote_id"), // Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used emojiId: uuid("emojiId").references(() => Emojis.id, { onDelete: "cascade", @@ -244,7 +245,7 @@ export const Markers = pgTable("Markers", { export const Likes = pgTable("Likes", { id: id(), - uri: uri(), + remoteId: text("remote_id"), likerId: uuid("likerId") .notNull() .references(() => Users.id, { @@ -472,7 +473,7 @@ export const NotificationsRelations = relations(Notifications, ({ one }) => ({ export const Notes = pgTable("Notes", { id: id(), - uri: uri(), + remoteId: text("remote_id"), authorId: uuid("authorId") .notNull() .references(() => Users.id, { @@ -600,7 +601,7 @@ export const Users = pgTable( "Users", { id: id(), - uri: uri(), + remoteId: text("remote_id"), username: text("username").notNull(), displayName: text("display_name"), password: text("password"), @@ -615,15 +616,6 @@ export const Users = pgTable( value: z.infer; }[] >(), - endpoints: jsonb("endpoints").$type | null>(), source: jsonb("source").$type>(), avatarId: uuid("avatarId").references(() => Medias.id, { onDelete: "set null", @@ -646,8 +638,6 @@ export const Users = pgTable( .notNull(), isIndexable: boolean("is_indexable").default(true).notNull(), sanctions: text("sanctions").array(), - publicKey: text("public_key").notNull(), - privateKey: text("private_key"), instanceId: uuid("instanceId").references(() => Instances.id, { onDelete: "cascade", onUpdate: "cascade", @@ -656,11 +646,7 @@ export const Users = pgTable( .default(false) .notNull(), }, - (table) => [ - uniqueIndex().on(table.uri), - index().on(table.username), - uniqueIndex().on(table.email), - ], + (table) => [index().on(table.username), uniqueIndex().on(table.email)], ); export const UsersRelations = relations(Users, ({ many, one }) => ({ diff --git a/packages/sdk/crypto.ts b/packages/sdk/crypto.ts index 7133fc7e..b61c9b72 100644 --- a/packages/sdk/crypto.ts +++ b/packages/sdk/crypto.ts @@ -13,15 +13,15 @@ const base64ToArrayBuffer = (base64: string): ArrayBuffer => * Signs a request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification. * * @see https://versia.pub/signatures - * @param privateKey - Private key of the User that is signing the request. - * @param authorUrl - URL of the User that is signing the request. + * @param privateKey - Private key of the instance that is signing the request. + * @param instance - URL of the instance that is signing the request. * @param req - Request to sign. * @param timestamp - (optional) Timestamp of the request. * @returns The signed request. */ export const sign = async ( privateKey: CryptoKey, - authorUrl: URL, + instance: URL, req: Request, timestamp = new Date(), ): Promise => { @@ -48,7 +48,7 @@ export const sign = async ( ...req.headers, "Versia-Signature": signatureBase64, "Versia-Signed-At": String(timestampSecs), - "Versia-Signed-By": authorUrl.href, + "Versia-Signed-By": instance.hostname, }, }); diff --git a/packages/sdk/entities/delete.ts b/packages/sdk/entities/delete.ts index 6df3d896..7a16b5fa 100644 --- a/packages/sdk/entities/delete.ts +++ b/packages/sdk/entities/delete.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import { DeleteSchema } from "../schemas/delete.ts"; import type { JSONObject } from "../types.ts"; -import { Entity } from "./entity.ts"; +import { Entity, Reference } from "./entity.ts"; export class Delete extends Entity { public static override name = "Delete"; @@ -10,6 +10,14 @@ export class Delete extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get deleted(): Reference { + return Reference.fromString(this.data.deleted); + } + public static override fromJSON(json: JSONObject): Promise { return DeleteSchema.parseAsync(json).then((u) => new Delete(u)); } diff --git a/packages/sdk/entities/entity.ts b/packages/sdk/entities/entity.ts index cd495399..a21beef6 100644 --- a/packages/sdk/entities/entity.ts +++ b/packages/sdk/entities/entity.ts @@ -15,3 +15,31 @@ export class Entity { return this.data; } } + +export class Reference { + public constructor( + public id: string, + public domain?: string, + ) {} + + public static fromString(str: string): Reference { + // Expect format: domain:id or id (if domain is the local instance) + // Handle IPv6 addresses in brackets + const chunks = str.split(":"); + if (chunks.length === 2) { + return new Reference(chunks[1], chunks[0]); + } + + if (chunks.length > 2) { + const domain = chunks.slice(0, -1).join(":"); + const id = chunks.at(-1) as string; + return new Reference(id, domain); + } + + return new Reference(str); + } + + public toString(): string { + return this.domain ? `${this.domain}:${this.id}` : this.id; + } +} diff --git a/packages/sdk/entities/extensions/likes.ts b/packages/sdk/entities/extensions/likes.ts index f8db6885..8615b3fc 100644 --- a/packages/sdk/entities/extensions/likes.ts +++ b/packages/sdk/entities/extensions/likes.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import { DislikeSchema, LikeSchema } from "../../schemas/extensions/likes.ts"; import type { JSONObject } from "../../types.ts"; -import { Entity } from "../entity.ts"; +import { Entity, Reference } from "../entity.ts"; export class Like extends Entity { public static override name = "pub.versia:likes/Like"; @@ -10,6 +10,14 @@ export class Like extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get liked(): Reference { + return Reference.fromString(this.data.liked); + } + public static override fromJSON(json: JSONObject): Promise { return LikeSchema.parseAsync(json).then((u) => new Like(u)); } @@ -22,6 +30,14 @@ export class Dislike extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get disliked(): Reference { + return Reference.fromString(this.data.disliked); + } + public static override fromJSON(json: JSONObject): Promise { return DislikeSchema.parseAsync(json).then((u) => new Dislike(u)); } diff --git a/packages/sdk/entities/extensions/polls.ts b/packages/sdk/entities/extensions/polls.ts index 995e324d..4c508552 100644 --- a/packages/sdk/entities/extensions/polls.ts +++ b/packages/sdk/entities/extensions/polls.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import { VoteSchema } from "../../schemas/extensions/polls.ts"; import type { JSONObject } from "../../types.ts"; -import { Entity } from "../entity.ts"; +import { Entity, Reference } from "../entity.ts"; export class Vote extends Entity { public static override name = "pub.versia:polls/Vote"; @@ -10,6 +10,14 @@ export class Vote extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get poll(): Reference { + return Reference.fromString(this.data.poll); + } + public static override fromJSON(json: JSONObject): Promise { return VoteSchema.parseAsync(json).then((u) => new Vote(u)); } diff --git a/packages/sdk/entities/extensions/reactions.ts b/packages/sdk/entities/extensions/reactions.ts index 5f3f785e..3299c248 100644 --- a/packages/sdk/entities/extensions/reactions.ts +++ b/packages/sdk/entities/extensions/reactions.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import { ReactionSchema } from "../../schemas/extensions/reactions.ts"; import type { JSONObject } from "../../types.ts"; -import { Entity } from "../entity.ts"; +import { Entity, Reference } from "../entity.ts"; export class Reaction extends Entity { public static override name = "pub.versia:reactions/Reaction"; @@ -10,6 +10,14 @@ export class Reaction extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get object(): Reference { + return Reference.fromString(this.data.object); + } + public static override fromJSON(json: JSONObject): Promise { return ReactionSchema.parseAsync(json).then((u) => new Reaction(u)); } diff --git a/packages/sdk/entities/extensions/reports.ts b/packages/sdk/entities/extensions/reports.ts index d15cfe48..31c2ad5e 100644 --- a/packages/sdk/entities/extensions/reports.ts +++ b/packages/sdk/entities/extensions/reports.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import { ReportSchema } from "../../schemas/extensions/reports.ts"; import type { JSONObject } from "../../types.ts"; -import { Entity } from "../entity.ts"; +import { Entity, Reference } from "../entity.ts"; export class Report extends Entity { public static override name = "pub.versia:reports/Report"; @@ -10,6 +10,14 @@ export class Report extends Entity { super(data); } + public get author(): Reference | null { + return this.data.author ? Reference.fromString(this.data.author) : null; + } + + public get reported(): Reference[] { + return this.data.reported.map((r) => Reference.fromString(r)); + } + public static override fromJSON(json: JSONObject): Promise { return ReportSchema.parseAsync(json).then((u) => new Report(u)); } diff --git a/packages/sdk/entities/extensions/share.ts b/packages/sdk/entities/extensions/share.ts index f3817744..ea2fb8ad 100644 --- a/packages/sdk/entities/extensions/share.ts +++ b/packages/sdk/entities/extensions/share.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import { ShareSchema } from "../../schemas/extensions/share.ts"; import type { JSONObject } from "../../types.ts"; -import { Entity } from "../entity.ts"; +import { Entity, Reference } from "../entity.ts"; export class Share extends Entity { public static override name = "pub.versia:share/Share"; @@ -10,6 +10,14 @@ export class Share extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get shared(): Reference { + return Reference.fromString(this.data.shared); + } + public static override fromJSON(json: JSONObject): Promise { return ShareSchema.parseAsync(json).then((u) => new Share(u)); } diff --git a/packages/sdk/entities/follow.ts b/packages/sdk/entities/follow.ts index d38d9728..9ba9db5f 100644 --- a/packages/sdk/entities/follow.ts +++ b/packages/sdk/entities/follow.ts @@ -6,7 +6,7 @@ import { UnfollowSchema, } from "../schemas/follow.ts"; import type { JSONObject } from "../types.ts"; -import { Entity } from "./entity.ts"; +import { Entity, Reference } from "./entity.ts"; export class Follow extends Entity { public static override name = "Follow"; @@ -15,6 +15,14 @@ export class Follow extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get followee(): Reference { + return Reference.fromString(this.data.followee); + } + public static override fromJSON(json: JSONObject): Promise { return FollowSchema.parseAsync(json).then((u) => new Follow(u)); } @@ -29,6 +37,14 @@ export class FollowAccept extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get follower(): Reference { + return Reference.fromString(this.data.follower); + } + public static override fromJSON(json: JSONObject): Promise { return FollowAcceptSchema.parseAsync(json).then( (u) => new FollowAccept(u), @@ -45,6 +61,14 @@ export class FollowReject extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get follower(): Reference { + return Reference.fromString(this.data.follower); + } + public static override fromJSON(json: JSONObject): Promise { return FollowRejectSchema.parseAsync(json).then( (u) => new FollowReject(u), @@ -59,6 +83,14 @@ export class Unfollow extends Entity { super(data); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get followee(): Reference { + return Reference.fromString(this.data.followee); + } + public static override fromJSON(json: JSONObject): Promise { return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u)); } diff --git a/packages/sdk/entities/index.ts b/packages/sdk/entities/index.ts index 2b80228f..8c9f25ff 100644 --- a/packages/sdk/entities/index.ts +++ b/packages/sdk/entities/index.ts @@ -8,7 +8,7 @@ export { VideoContentFormat, } from "./contentformat.ts"; export { Delete } from "./delete.ts"; -export { Entity } from "./entity.ts"; +export { Entity, Reference } from "./entity.ts"; export { Dislike, Like } from "./extensions/likes.ts"; export { Vote } from "./extensions/polls.ts"; export { Reaction } from "./extensions/reactions.ts"; diff --git a/packages/sdk/entities/note.ts b/packages/sdk/entities/note.ts index 7314e763..3a84cb68 100644 --- a/packages/sdk/entities/note.ts +++ b/packages/sdk/entities/note.ts @@ -2,7 +2,7 @@ import type { z } from "zod"; import { NoteSchema } from "../schemas/note.ts"; import type { JSONObject } from "../types.ts"; import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts"; -import { Entity } from "./entity.ts"; +import { Entity, Reference } from "./entity.ts"; export class Note extends Entity { public static override name = "Note"; @@ -15,6 +15,35 @@ export class Note extends Entity { return NoteSchema.parseAsync(json).then((n) => new Note(n)); } + public get author(): Reference { + return Reference.fromString(this.data.author); + } + + public get group(): Reference | null { + if ( + !this.data.group || + ["public", "followers"].includes(this.data.group) + ) { + return null; + } + + return Reference.fromString(this.data.group); + } + + public get mentions(): Reference[] { + return this.data.mentions.map((m) => Reference.fromString(m)); + } + + public get quotes(): Reference | null { + return this.data.quotes ? Reference.fromString(this.data.quotes) : null; + } + + public get repliesTo(): Reference | null { + return this.data.replies_to + ? Reference.fromString(this.data.replies_to) + : null; + } + public get attachments(): NonTextContentFormat[] { return ( this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? [] diff --git a/packages/sdk/http.ts b/packages/sdk/http.ts index 02e94175..e87aef6a 100644 --- a/packages/sdk/http.ts +++ b/packages/sdk/http.ts @@ -1,10 +1,12 @@ import { sign } from "./crypto.ts"; import { Collection, URICollection } from "./entities/collection.ts"; -import type { Entity } from "./entities/entity.ts"; +import type { Entity, Reference } from "./entities/entity.ts"; +import { InstanceMetadata } from "./entities/instancemetadata.ts"; import { homepage, version } from "./package.json" with { type: "json" }; import { WebFingerSchema } from "./schemas/webfinger.ts"; const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`; +const CONTENT_TYPE = "application/vnd.versia+json"; /** * A class that handles fetching Versia entities @@ -22,22 +24,22 @@ const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`; export class FederationRequester { public constructor( private readonly privateKey: CryptoKey, - private readonly authorUrl: URL, + private readonly instance: URL, ) {} - public async fetchEntity( + public async fetchSigned( url: URL, - expectedType: T, + entityType: T, ): Promise> { const req = new Request(url, { method: "GET", headers: { - Accept: "application/json", + Accept: CONTENT_TYPE, "User-Agent": DEFAULT_UA, }, }); - const finalReq = await sign(this.privateKey, this.authorUrl, req); + const finalReq = await sign(this.privateKey, this.instance, req); const res = await fetch(finalReq); @@ -49,79 +51,116 @@ export class FederationRequester { const contentType = res.headers.get("Content-Type"); - if (!contentType?.includes("application/json")) { + if ( + !( + contentType?.includes("application/vnd.versia+json") && + contentType?.includes("charset=utf-8") + ) + ) { throw new Error( - `Expected JSON response from ${url.toString()}, got "${contentType}"`, + `Expected application/vnd.versia+json; charset=utf-8 response from ${url.toString()}, got "${contentType}"`, ); } const jsonData = await res.json(); const type = jsonData.type; - if (type && type !== expectedType.name) { + if ( + (!type || type !== entityType.name) && + // (URI)Collections don't have a type field + ![Collection, URICollection].some((et) => et === entityType) + ) { throw new Error( - `Expected entity type "${expectedType.name}", got "${type}"`, + `Expected entity type "${entityType.name}", got "${type}"`, ); } - const entity = await expectedType.fromJSON(jsonData); + const entity = await entityType.fromJSON(jsonData); return entity as InstanceType; } - public async postEntity(url: URL, entity: Entity): Promise { + public fetchEntity( + reference: Reference, + entityType: T, + ): Promise> { + const url = new URL( + `/.versia/v0.6/entities/${encodeURIComponent( + entityType.name, + )}/${encodeURIComponent(reference.id)}`, + `https://${reference.domain}`, + ); + + return this.fetchSigned(url, entityType); + } + + public async postEntity(domain: string, entity: Entity): Promise { + const url = new URL("/.versia/v0.6/inbox", `https://${domain}`); + const req = new Request(url, { method: "POST", headers: { - Accept: "application/json", + Accept: CONTENT_TYPE, "User-Agent": DEFAULT_UA, - "Content-Type": "application/json; charset=utf-8", + "Content-Type": "application/vnd.versia+json; charset=utf-8", }, body: JSON.stringify(entity.toJSON()), }); - const finalReq = await sign(this.privateKey, this.authorUrl, req); + const finalReq = await sign(this.privateKey, this.instance, req); return fetch(finalReq); } /** * Recursively go through a Collection of entities until reaching the end - * @param url URL to reach the Collection - * @param expectedType + * @param reference Entity Reference + * @param entityType + * @param collectionItemType * @param options.limit Limit the number of entities to fetch */ - public async resolveCollection( - url: URL, - expectedType: T, + public async resolveCollection< + E extends typeof Entity, + T extends typeof Entity, + >( + reference: Reference, + collectionName: string, + entityType: E, + collectionItemType: T, options?: { limit?: number; }, ): Promise[]> { + const url = new URL( + `/.versia/v0.6/entities/${encodeURIComponent( + entityType.name, + )}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent( + collectionName, + )}`, + `https://${reference.domain}`, + ); + const entities: InstanceType[] = []; - let nextUrl: URL | null = url; let limit = options?.limit ?? Number.POSITIVE_INFINITY; - while (nextUrl && limit > 0) { - const collection: Collection = await this.fetchEntity( - nextUrl, - Collection, - ); + let collection = await this.fetchSigned(url, Collection); + const total = collection.data.total; - for (const entity of collection.data.items) { - if (entity.type === expectedType.name) { - entities.push( - (await expectedType.fromJSON( - entity, - )) as InstanceType, - ); - } + while (collection && limit > 0) { + entities.push( + ...collection.data.items.map( + (item) => + collectionItemType.fromJSON(item) as InstanceType, + ), + ); + limit -= collection.data.items.length; + + if (entities.length >= total) { + break; } - nextUrl = collection.data.next - ? new URL(collection.data.next) - : null; - limit -= collection.data.items.length; + url.searchParams.set("offset", entities.length.toString()); + collection = await this.fetchSigned(url, Collection); } return entities; @@ -129,33 +168,46 @@ export class FederationRequester { /** * Recursively go through a URICollection of entities until reaching the end - * @param url URL to reach the Collection + * @param reference Entity Reference + * @param entityType * @param options.limit Limit the number of entities to fetch */ - public async resolveURICollection( - url: URL, + public async resolveURICollection( + reference: Reference, + collectionName: string, + entityType: E, options?: { limit?: number; }, - ): Promise { - const entities: string[] = []; - let nextUrl: URL | null = url; + ): Promise { + const url = new URL( + `/.versia/v0.6/entities/${encodeURIComponent( + entityType.name, + )}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent( + collectionName, + )}`, + `https://${reference.domain}`, + ); + + const uris: string[] = []; let limit = options?.limit ?? Number.POSITIVE_INFINITY; - while (nextUrl && limit > 0) { - const collection: URICollection = await this.fetchEntity( - nextUrl, - URICollection, - ); + let collection = await this.fetchSigned(url, URICollection); + const total = collection.data.total; - entities.push(...collection.data.items); - nextUrl = collection.data.next - ? new URL(collection.data.next) - : null; + while (collection && limit > 0) { + uris.push(...collection.data.items); limit -= collection.data.items.length; + + if (uris.length >= total) { + break; + } + + url.searchParams.set("offset", uris.length.toString()); + collection = await this.fetchSigned(url, URICollection); } - return entities.map((u) => new URL(u)); + return uris; } /** @@ -164,21 +216,21 @@ export class FederationRequester { */ public static async resolveWebFinger( username: string, - hostname: string, - contentType = "application/json", - serverUrl = `https://${hostname}`, + domain: string, + contentType = "application/vnd.versia+json", + serverUrl = `https://${domain}`, ): Promise { const res = await fetch( new URL( `/.well-known/webfinger?${new URLSearchParams({ - resource: `acct:${username}@${hostname}`, + resource: `acct:${username}@${domain}`, })}`, serverUrl, ), { method: "GET", headers: { - Accept: "application/json", + Accept: "application/jrd+json, application/json", "User-Agent": DEFAULT_UA, }, }, @@ -204,4 +256,57 @@ export class FederationRequester { return new URL(selfLink.href); } + + /** + * Resolve instance metadata from a domain + * + * Fetches well-known for version discovery, and if versia is supported, fetches the instance metadata + * @param domain + */ + public async resolveInstance(domain: string): Promise { + const wellKnownUrl = new URL( + "/.well-known/versia", + `https://${domain}`, + ); + + const wellKnownRes = await fetch(wellKnownUrl, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": DEFAULT_UA, + }, + }); + + if (!wellKnownRes.ok) { + throw new Error( + `Failed to fetch well-known from ${wellKnownUrl.toString()}: got HTTP code ${wellKnownRes.status} with body "${await wellKnownRes.text()}"`, + ); + } + + const wellKnownData = await wellKnownRes.json(); + + if ( + !( + wellKnownData.versions && + Array.isArray(wellKnownData.versions) && + wellKnownData.versions.includes("0.6.0") + ) + ) { + throw new Error( + `Instance at ${domain} does not support Versia v0.6`, + ); + } + + const metadataUrl = new URL( + "/.versia/v0.6/instance", + `https://${domain}`, + ); + + const metadataRes = await this.fetchSigned( + metadataUrl, + InstanceMetadata, + ); + + return metadataRes; + } } diff --git a/packages/sdk/schemas/collection.ts b/packages/sdk/schemas/collection.ts index 92accefb..993d577a 100644 --- a/packages/sdk/schemas/collection.ts +++ b/packages/sdk/schemas/collection.ts @@ -1,16 +1,13 @@ import { z } from "zod"; -import { u64, url } from "./common.ts"; +import { u64 } from "./common.ts"; +import { ReferenceSchema } from "./entity.ts"; export const CollectionSchema = z.strictObject({ - author: url.nullable(), - first: url, - last: url, + author: ReferenceSchema.nullable(), total: u64, - next: url.nullable(), - previous: url.nullable(), items: z.array(z.any()), }); export const URICollectionSchema = CollectionSchema.extend({ - items: z.array(url), + items: z.array(ReferenceSchema), }); diff --git a/packages/sdk/schemas/contentformat.ts b/packages/sdk/schemas/contentformat.ts index 42e5c6a5..4ee35569 100644 --- a/packages/sdk/schemas/contentformat.ts +++ b/packages/sdk/schemas/contentformat.ts @@ -2,26 +2,6 @@ import { types } from "mime-types"; import { z } from "zod"; import { f64, u64 } from "./common.ts"; -const hashSizes = { - 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 allMimeTypes = Object.values(types) as [string, ...string[]]; const textMimeTypes = Object.values(types).filter((v) => v.startsWith("text/"), @@ -46,16 +26,7 @@ export const ContentFormatSchema = z.partialRecord( remote: z.boolean(), description: z.string().nullish(), size: u64.nullish(), - hash: z - .strictObject( - Object.fromEntries( - Object.entries(hashSizes).map(([k, v]) => [ - k, - z.string().length(v).nullish(), - ]), - ), - ) - .nullish(), + hash: z.hash("sha256").nullish(), thumbhash: z.string().nullish(), width: u64.nullish(), height: u64.nullish(), diff --git a/packages/sdk/schemas/delete.ts b/packages/sdk/schemas/delete.ts index 9cd7d9a8..b0a4733f 100644 --- a/packages/sdk/schemas/delete.ts +++ b/packages/sdk/schemas/delete.ts @@ -1,11 +1,9 @@ import { z } from "zod"; -import { url } from "./common.ts"; -import { EntitySchema } from "./entity.ts"; +import { ReferenceSchema, TransientEntitySchema } from "./entity.ts"; -export const DeleteSchema = EntitySchema.extend({ - uri: z.null().optional(), +export const DeleteSchema = TransientEntitySchema.extend({ type: z.literal("Delete"), - author: url.nullable(), + author: ReferenceSchema, deleted_type: z.string(), - deleted: url, + deleted: ReferenceSchema, }); diff --git a/packages/sdk/schemas/entity.ts b/packages/sdk/schemas/entity.ts index 4cb6f7b7..2ef7dd8e 100644 --- a/packages/sdk/schemas/entity.ts +++ b/packages/sdk/schemas/entity.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { isISOString } from "../regex.ts"; -import { url } from "./common.ts"; import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts"; export const ExtensionPropertySchema = z @@ -10,14 +9,26 @@ export const ExtensionPropertySchema = z }) .catchall(z.any()); +export const ReferenceSchema = z.string(); + export const EntitySchema = z.strictObject({ // biome-ignore lint/style/useNamingConvention: required for JSON schema $schema: z.url().nullish(), - id: z.string().max(512), + id: z + .string() + .max(512) + .regex( + // a-z, A-Z, 0-9, - and _ + /^[A-Za-z0-9\-_]+$/, + "can only contain alphanumeric characters, hyphens and underscores", + ), created_at: z .string() - .refine((v) => isISOString(v), "must be a valid ISO8601 datetime"), - uri: url, + .refine((v) => isISOString(v), "must be a valid RFC 3339 datetime"), type: z.string(), extensions: ExtensionPropertySchema.nullish(), }); + +export const TransientEntitySchema = EntitySchema.extend({ + id: z.null().optional(), +}); diff --git a/packages/sdk/schemas/extensions/groups.ts b/packages/sdk/schemas/extensions/groups.ts index 8c246056..6d56f49c 100644 --- a/packages/sdk/schemas/extensions/groups.ts +++ b/packages/sdk/schemas/extensions/groups.ts @@ -1,41 +1,38 @@ import { z } from "zod"; -import { url } from "../common.ts"; import { TextContentFormatSchema } from "../contentformat.ts"; -import { EntitySchema } from "../entity.ts"; +import { + EntitySchema, + ReferenceSchema, + TransientEntitySchema, +} from "../entity.ts"; export const GroupSchema = EntitySchema.extend({ type: z.literal("pub.versia:groups/Group"), name: TextContentFormatSchema.nullish(), description: TextContentFormatSchema.nullish(), - open: z.boolean().nullish(), - members: url, - notes: url.nullish(), + open: z.boolean(), }); -export const GroupSubscribeSchema = EntitySchema.extend({ +export const GroupSubscribeSchema = TransientEntitySchema.extend({ type: z.literal("pub.versia:groups/Subscribe"), - uri: z.null().optional(), - subscriber: url, - group: url, + subscriber: ReferenceSchema, + group: ReferenceSchema, }); -export const GroupUnsubscribeSchema = EntitySchema.extend({ +export const GroupUnsubscribeSchema = TransientEntitySchema.extend({ type: z.literal("pub.versia:groups/Unsubscribe"), - uri: z.null().optional(), - subscriber: url, - group: url, + subscriber: ReferenceSchema, + group: ReferenceSchema, }); -export const GroupSubscribeAcceptSchema = EntitySchema.extend({ +export const GroupSubscribeAcceptSchema = TransientEntitySchema.extend({ type: z.literal("pub.versia:groups/SubscribeAccept"), - uri: z.null().optional(), - subscriber: url, - group: url, + subscriber: ReferenceSchema, + group: ReferenceSchema, }); -export const GroupSubscribeRejectSchema = EntitySchema.extend({ +export const GroupSubscribeRejectSchema = TransientEntitySchema.extend({ type: z.literal("pub.versia:groups/SubscribeReject"), - uri: z.null().optional(), - subscriber: url, - group: url, + subscriber: ReferenceSchema, + group: ReferenceSchema, }); diff --git a/packages/sdk/schemas/extensions/likes.ts b/packages/sdk/schemas/extensions/likes.ts index 75208fb7..b9c14fbc 100644 --- a/packages/sdk/schemas/extensions/likes.ts +++ b/packages/sdk/schemas/extensions/likes.ts @@ -1,15 +1,14 @@ import { z } from "zod"; -import { url } from "../common.ts"; -import { EntitySchema } from "../entity.ts"; +import { EntitySchema, ReferenceSchema } from "../entity.ts"; export const LikeSchema = EntitySchema.extend({ type: z.literal("pub.versia:likes/Like"), - author: url, - liked: url, + author: ReferenceSchema, + liked: ReferenceSchema, }); export const DislikeSchema = EntitySchema.extend({ type: z.literal("pub.versia:likes/Dislike"), - author: url, - disliked: url, + author: ReferenceSchema, + disliked: ReferenceSchema, }); diff --git a/packages/sdk/schemas/extensions/migration.ts b/packages/sdk/schemas/extensions/migration.ts index da215ce3..844bbcfa 100644 --- a/packages/sdk/schemas/extensions/migration.ts +++ b/packages/sdk/schemas/extensions/migration.ts @@ -1,15 +1,13 @@ import { z } from "zod"; -import { url } from "../common.ts"; -import { EntitySchema } from "../entity.ts"; +import { ReferenceSchema, TransientEntitySchema } from "../entity.ts"; -export const MigrationSchema = EntitySchema.extend({ +export const MigrationSchema = TransientEntitySchema.extend({ type: z.literal("pub.versia:migration/Migration"), - uri: z.null().optional(), - author: url, - destination: url, + author: ReferenceSchema, + destination: ReferenceSchema, }); export const MigrationExtensionSchema = z.strictObject({ - previous: url, - new: url.nullish(), + previous: ReferenceSchema, + new: ReferenceSchema.nullish(), }); diff --git a/packages/sdk/schemas/extensions/polls.ts b/packages/sdk/schemas/extensions/polls.ts index 09741b8c..8455c295 100644 --- a/packages/sdk/schemas/extensions/polls.ts +++ b/packages/sdk/schemas/extensions/polls.ts @@ -1,13 +1,13 @@ import { z } from "zod"; import { isISOString } from "../../regex.ts"; -import { u64, url } from "../common.ts"; +import { u64 } from "../common.ts"; import { TextContentFormatSchema } from "../contentformat.ts"; -import { EntitySchema } from "../entity.ts"; +import { EntitySchema, ReferenceSchema } from "../entity.ts"; export const VoteSchema = EntitySchema.extend({ type: z.literal("pub.versia:polls/Vote"), - author: url, - poll: url, + author: ReferenceSchema, + poll: ReferenceSchema, option: u64, }); @@ -17,6 +17,6 @@ export const PollExtensionSchema = z.strictObject({ multiple_choice: z.boolean(), expires_at: z .string() - .refine((v) => isISOString(v), "must be a valid ISO8601 datetime") + .refine((v) => isISOString(v), "must be a valid RFC 3339 datetime") .nullish(), }); diff --git a/packages/sdk/schemas/extensions/reactions.ts b/packages/sdk/schemas/extensions/reactions.ts index 99b7162d..4fddbb4e 100644 --- a/packages/sdk/schemas/extensions/reactions.ts +++ b/packages/sdk/schemas/extensions/reactions.ts @@ -1,10 +1,9 @@ import { z } from "zod"; -import { url } from "../common.ts"; -import { EntitySchema } from "../entity.ts"; +import { EntitySchema, ReferenceSchema } from "../entity.ts"; export const ReactionSchema = EntitySchema.extend({ type: z.literal("pub.versia:reactions/Reaction"), - author: url, - object: url, + author: ReferenceSchema, + object: ReferenceSchema, content: z.string().min(1).max(256), }); diff --git a/packages/sdk/schemas/extensions/reports.ts b/packages/sdk/schemas/extensions/reports.ts index c49bc8db..9b925b2d 100644 --- a/packages/sdk/schemas/extensions/reports.ts +++ b/packages/sdk/schemas/extensions/reports.ts @@ -1,12 +1,10 @@ import { z } from "zod"; -import { url } from "../common.ts"; -import { EntitySchema } from "../entity.ts"; +import { ReferenceSchema, TransientEntitySchema } from "../entity.ts"; -export const ReportSchema = EntitySchema.extend({ +export const ReportSchema = TransientEntitySchema.extend({ type: z.literal("pub.versia:reports/Report"), - uri: z.null().optional(), - author: url.nullish(), - reported: z.array(url), + author: ReferenceSchema.nullish(), + reported: z.array(ReferenceSchema), tags: z.array(z.string()), comment: z .string() diff --git a/packages/sdk/schemas/extensions/share.ts b/packages/sdk/schemas/extensions/share.ts index 20968a08..31c46c66 100644 --- a/packages/sdk/schemas/extensions/share.ts +++ b/packages/sdk/schemas/extensions/share.ts @@ -1,9 +1,8 @@ import { z } from "zod"; -import { url } from "../common.ts"; -import { EntitySchema } from "../entity.ts"; +import { EntitySchema, ReferenceSchema } from "../entity.ts"; export const ShareSchema = EntitySchema.extend({ type: z.literal("pub.versia:share/Share"), - author: url, - shared: url, + author: ReferenceSchema, + shared: ReferenceSchema, }); diff --git a/packages/sdk/schemas/extensions/vanity.ts b/packages/sdk/schemas/extensions/vanity.ts index 6d47136a..cf168226 100644 --- a/packages/sdk/schemas/extensions/vanity.ts +++ b/packages/sdk/schemas/extensions/vanity.ts @@ -7,11 +7,11 @@ import { z } from "zod"; import { ianaTimezoneRegex, isISOString } from "../../regex.ts"; -import { url } from "../common.ts"; import { AudioContentFormatSchema, ImageContentFormatSchema, } from "../contentformat.ts"; +import { ReferenceSchema } from "../entity.ts"; export const VanityExtensionSchema = z.strictObject({ avatar_overlays: z.array(ImageContentFormatSchema).nullish(), @@ -21,24 +21,21 @@ export const VanityExtensionSchema = z.strictObject({ pronouns: z.record( z.string(), z.array( - z.union([ - z.strictObject({ - subject: z.string(), - object: z.string(), - dependent_possessive: z.string(), - independent_possessive: z.string(), - reflexive: z.string(), - }), - z.string(), - ]), + z.strictObject({ + subject: z.string(), + object: z.string(), + dependent_possessive: z.string(), + independent_possessive: z.string(), + reflexive: z.string(), + }), ), ), birthday: z .string() - .refine((v) => isISOString(v), "must be a valid ISO8601 datetime") + .refine((v) => isISOString(v), "must be a valid RFC 3339 datetime") .nullish(), location: z.string().nullish(), - aliases: z.array(url).nullish(), + aliases: z.array(ReferenceSchema).nullish(), timezone: z .string() .regex(ianaTimezoneRegex, "must be a valid IANA timezone") diff --git a/packages/sdk/schemas/follow.ts b/packages/sdk/schemas/follow.ts index 62c89c32..67b7d731 100644 --- a/packages/sdk/schemas/follow.ts +++ b/packages/sdk/schemas/follow.ts @@ -1,31 +1,26 @@ import { z } from "zod"; -import { url } from "./common.ts"; -import { EntitySchema } from "./entity.ts"; +import { ReferenceSchema, TransientEntitySchema } from "./entity.ts"; -export const FollowSchema = EntitySchema.extend({ +export const FollowSchema = TransientEntitySchema.extend({ type: z.literal("Follow"), - uri: z.null().optional(), - author: url, - followee: url, + author: ReferenceSchema, + followee: ReferenceSchema, }); -export const FollowAcceptSchema = EntitySchema.extend({ +export const FollowAcceptSchema = TransientEntitySchema.extend({ type: z.literal("FollowAccept"), - uri: z.null().optional(), - author: url, - follower: url, + author: ReferenceSchema, + follower: ReferenceSchema, }); -export const FollowRejectSchema = EntitySchema.extend({ +export const FollowRejectSchema = TransientEntitySchema.extend({ type: z.literal("FollowReject"), - uri: z.null().optional(), - author: url, - follower: url, + author: ReferenceSchema, + follower: ReferenceSchema, }); -export const UnfollowSchema = EntitySchema.extend({ +export const UnfollowSchema = TransientEntitySchema.extend({ type: z.literal("Unfollow"), - uri: z.null().optional(), - author: url, - followee: url, + author: ReferenceSchema, + followee: ReferenceSchema, }); diff --git a/packages/sdk/schemas/index.ts b/packages/sdk/schemas/index.ts index aa061705..3c7efdfc 100644 --- a/packages/sdk/schemas/index.ts +++ b/packages/sdk/schemas/index.ts @@ -8,7 +8,11 @@ export { VideoContentFormatSchema, } from "./contentformat.ts"; export { DeleteSchema } from "./delete.ts"; -export { EntitySchema } from "./entity.ts"; +export { + EntitySchema, + ReferenceSchema, + TransientEntitySchema, +} from "./entity.ts"; export { DislikeSchema, LikeSchema } from "./extensions/likes.ts"; export { VoteSchema } from "./extensions/polls.ts"; export { ReactionSchema } from "./extensions/reactions.ts"; diff --git a/packages/sdk/schemas/instance.ts b/packages/sdk/schemas/instance.ts index e62648b5..e014b00e 100644 --- a/packages/sdk/schemas/instance.ts +++ b/packages/sdk/schemas/instance.ts @@ -1,13 +1,10 @@ import { z } from "zod"; import { extensionRegex, semverRegex } from "../regex.ts"; -import { url } from "./common.ts"; import { ImageContentFormatSchema } from "./contentformat.ts"; -import { EntitySchema } from "./entity.ts"; +import { TransientEntitySchema } from "./entity.ts"; -export const InstanceMetadataSchema = EntitySchema.extend({ +export const InstanceMetadataSchema = TransientEntitySchema.extend({ type: z.literal("InstanceMetadata"), - id: z.null().optional(), - uri: z.null().optional(), name: z.string().min(1), software: z.strictObject({ name: z.string().min(1), @@ -28,14 +25,11 @@ export const InstanceMetadataSchema = EntitySchema.extend({ ), }), description: z.string().nullish(), - host: z.string(), - shared_inbox: url.nullish(), + domain: z.string(), public_key: z.strictObject({ key: z.string().min(1), algorithm: z.literal("ed25519"), }), - moderators: url.nullish(), - admins: url.nullish(), logo: ImageContentFormatSchema.nullish(), banner: ImageContentFormatSchema.nullish(), }); diff --git a/packages/sdk/schemas/note.ts b/packages/sdk/schemas/note.ts index 9d58beca..6236a46a 100644 --- a/packages/sdk/schemas/note.ts +++ b/packages/sdk/schemas/note.ts @@ -4,13 +4,13 @@ import { NonTextContentFormatSchema, TextContentFormatSchema, } from "./contentformat.ts"; -import { EntitySchema } from "./entity.ts"; +import { EntitySchema, ReferenceSchema } from "./entity.ts"; import { PollExtensionSchema } from "./extensions/polls.ts"; export const NoteSchema = EntitySchema.extend({ type: z.literal("Note"), - attachments: z.array(NonTextContentFormatSchema).nullish(), - author: url, + attachments: z.array(NonTextContentFormatSchema), + author: ReferenceSchema, category: z .enum([ "microblog", @@ -23,16 +23,6 @@ export const NoteSchema = EntitySchema.extend({ ]) .nullish(), content: TextContentFormatSchema.nullish(), - collections: z - .strictObject({ - replies: url, - quotes: url, - "pub.versia:reactions/Reactions": url.nullish(), - "pub.versia:share/Shares": url.nullish(), - "pub.versia:likes/Likes": url.nullish(), - "pub.versia:likes/Dislikes": url.nullish(), - }) - .catchall(url), device: z .strictObject({ name: z.string(), @@ -40,22 +30,20 @@ export const NoteSchema = EntitySchema.extend({ url: url.nullish(), }) .nullish(), - group: url.or(z.enum(["public", "followers"])).nullish(), - is_sensitive: z.boolean().nullish(), - mentions: z.array(url).nullish(), - previews: z - .array( - z.strictObject({ - link: url, - title: z.string(), - description: z.string().nullish(), - image: url.nullish(), - icon: url.nullish(), - }), - ) - .nullish(), - quotes: url.nullish(), - replies_to: url.nullish(), + group: ReferenceSchema.or(z.enum(["public", "followers"])).nullish(), + is_sensitive: z.boolean(), + mentions: z.array(ReferenceSchema), + previews: z.array( + z.strictObject({ + link: url, + title: z.string(), + description: z.string().nullish(), + image: url.nullish(), + icon: url.nullish(), + }), + ), + quotes: ReferenceSchema.nullish(), + replies_to: ReferenceSchema.nullish(), subject: z.string().nullish(), extensions: EntitySchema.shape.extensions .unwrap() diff --git a/packages/sdk/schemas/user.ts b/packages/sdk/schemas/user.ts index aeaf1256..d0f89982 100644 --- a/packages/sdk/schemas/user.ts +++ b/packages/sdk/schemas/user.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { url } from "./common.ts"; import { ImageContentFormatSchema, TextContentFormatSchema, @@ -8,25 +7,17 @@ import { EntitySchema } from "./entity.ts"; import { MigrationExtensionSchema } from "./extensions/migration.ts"; import { VanityExtensionSchema } from "./extensions/vanity.ts"; -export const PublicKeyDataSchema = z.strictObject({ - key: z.string().min(1), - actor: url, - algorithm: z.literal("ed25519"), -}); - export const UserSchema = EntitySchema.extend({ type: z.literal("User"), avatar: ImageContentFormatSchema.nullish(), bio: TextContentFormatSchema.nullish(), display_name: z.string().nullish(), - fields: z - .array( - z.strictObject({ - key: TextContentFormatSchema, - value: TextContentFormatSchema, - }), - ) - .nullish(), + fields: z.array( + z.strictObject({ + key: TextContentFormatSchema, + value: TextContentFormatSchema, + }), + ), username: z .string() .min(1) @@ -35,20 +26,8 @@ export const UserSchema = EntitySchema.extend({ "must be alphanumeric, and may contain _ or -", ), header: ImageContentFormatSchema.nullish(), - public_key: PublicKeyDataSchema, - manually_approves_followers: z.boolean().nullish(), - indexable: z.boolean().nullish(), - inbox: url, - collections: z - .object({ - featured: url, - followers: url, - following: url, - outbox: url, - "pub.versia:likes/Likes": url.nullish(), - "pub.versia:likes/Dislikes": url.nullish(), - }) - .catchall(url), + manually_approves_followers: z.boolean(), + indexable: z.boolean(), extensions: EntitySchema.shape.extensions .unwrap() .unwrap() diff --git a/types/api.ts b/types/api.ts index 22fbeda2..026904e9 100644 --- a/types/api.ts +++ b/types/api.ts @@ -29,6 +29,7 @@ export interface ApiRouteExports { } export type KnownEntity = + | VersiaEntities.Entity | VersiaEntities.Note | VersiaEntities.InstanceMetadata | VersiaEntities.User