mirror of
https://github.com/versia-pub/server.git
synced 2026-04-27 12:49:16 +02:00
Merge pull request #43 from versia-pub/feat/v0.6
Upgrade to Versia Protocol v0.6
This commit is contained in:
commit
63f5136584
72 changed files with 4344 additions and 2389 deletions
|
|
@ -28,7 +28,7 @@ export const refetchUserCommand = defineCommand(
|
|||
const spinner = ora("Refetching user").start();
|
||||
|
||||
try {
|
||||
await User.fromVersia(user.uri);
|
||||
await User.fromVersia(user.reference);
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`Failed to refetch user ${chalk.gray(user.data.username)}`,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const retrieveUser = async (
|
|||
): Promise<User | null> => {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
default = pkgs.mkShell rec {
|
||||
libPath = with pkgs;
|
||||
lib.makeLibraryPath [
|
||||
vips
|
||||
stdenv.cc.cc.lib
|
||||
];
|
||||
|
||||
|
|
@ -55,7 +56,6 @@
|
|||
|
||||
buildInputs = with pkgs; [
|
||||
bun
|
||||
vips
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
nix-ld
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
"hono-openapi": "~1.1.2",
|
||||
"hono-rate-limiter": "~0.5.1",
|
||||
"html-to-text": "~9.0.5",
|
||||
"ioredis": "~5.8.2",
|
||||
"ioredis": "5.9.2",
|
||||
"ip-matching": "~2.1.2",
|
||||
"iso-639-1": "~3.1.5",
|
||||
"linkify-html": "~4.3.2",
|
||||
|
|
@ -158,6 +158,7 @@
|
|||
"dependencies": {
|
||||
"@bull-board/api": "catalog:",
|
||||
"@bull-board/hono": "catalog:",
|
||||
"@clerc/core": "catalog:",
|
||||
"@clerc/plugin-completions": "catalog:",
|
||||
"@clerc/plugin-friendly-error": "catalog:",
|
||||
"@clerc/plugin-help": "catalog:",
|
||||
|
|
@ -180,7 +181,6 @@
|
|||
"blurhash": "catalog:",
|
||||
"bullmq": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"@clerc/core": "catalog:",
|
||||
"confbox": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"feed": "catalog:",
|
||||
|
|
@ -216,5 +216,8 @@
|
|||
"zod": "catalog:",
|
||||
"zod-openapi": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"bun-bagel@1.2.0": "patches/bun-bagel@1.2.0.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ 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);
|
||||
|
||||
return context.json(newUser.toApi(false), 200);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,14 @@ export default apiRoute((app) =>
|
|||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const foundAccount = await User.resolve(uri);
|
||||
const accountData = await Instance.federationRequester.fetchSigned(
|
||||
uri,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
|
||||
const foundAccount = await User.fromVersia(accountData, instance);
|
||||
|
||||
if (foundAccount) {
|
||||
return context.json(foundAccount.toApi(), 200);
|
||||
}
|
||||
|
||||
throw ApiError.accountNotFound();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
accounts.push(foundAccount);
|
||||
}
|
||||
} else {
|
||||
accounts.push(
|
||||
|
|
|
|||
|
|
@ -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,12 +188,21 @@ 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);
|
||||
const accountData =
|
||||
await Instance.federationRequester.fetchSigned(
|
||||
uri,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
|
||||
const newUser = await User.fromVersia(
|
||||
accountData,
|
||||
instance,
|
||||
);
|
||||
|
||||
if (newUser) {
|
||||
return context.json(
|
||||
{
|
||||
accounts: [newUser.toApi()],
|
||||
|
|
@ -204,7 +214,6 @@ export default apiRoute((app) =>
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accountResults = await searchManager.searchAccounts(
|
||||
q,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -65,6 +65,7 @@ export default apiRoute((app) => {
|
|||
|
||||
const jwtPayload = (await verify(state, config.authentication.key, {
|
||||
iss: config.http.base_url.toString(),
|
||||
alg: "HS256",
|
||||
})) as {
|
||||
flow: string;
|
||||
link?: boolean;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
324
packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts
vendored
Normal file
324
packages/api/routes/versia/v0.6/[entity_type]/[id]/collections/[collection_type].ts
vendored
Normal file
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
178
packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts
vendored
Normal file
178
packages/api/routes/versia/v0.6/[entity_type]/[id]/index.ts
vendored
Normal file
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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();
|
||||
|
|
@ -43,61 +40,64 @@ mock(new URL("/.well-known/versia", instanceUrl).href, {
|
|||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
versions: ["0.6.0"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock(new URL("/.versia/v0.6/instance", instanceUrl).href, {
|
||||
response: {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.versia+json; charset=utf-8",
|
||||
},
|
||||
data: new VersiaEntities.InstanceMetadata({
|
||||
type: "InstanceMetadata",
|
||||
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",
|
||||
"Content-Type": "application/vnd.versia+json; charset=utf-8",
|
||||
},
|
||||
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 +111,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: "<p>Hello!</p>",
|
||||
|
|
@ -133,10 +133,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 +142,17 @@ describe("Inbox Tests", () => {
|
|||
});
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
instanceKeys.privateKey,
|
||||
instanceUrl,
|
||||
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()),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -165,12 +162,16 @@ describe("Inbox Tests", () => {
|
|||
body: signedRequest.body,
|
||||
});
|
||||
|
||||
console.log(await response.text());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
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 +180,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,
|
||||
new URL(exampleRequest.data.author),
|
||||
instanceKeys.privateKey,
|
||||
instanceUrl,
|
||||
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 +210,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 +220,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 +232,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,
|
||||
new URL(exampleRequest.data.author),
|
||||
instanceKeys.privateKey,
|
||||
instanceUrl,
|
||||
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 +263,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 +307,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 +318,7 @@ describe("Inbox Tests", () => {
|
|||
name: ":neocat:",
|
||||
url: {
|
||||
"image/webp": {
|
||||
hash: {
|
||||
sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
|
||||
},
|
||||
hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
|
||||
size: 4664,
|
||||
width: 256,
|
||||
height: 256,
|
||||
|
|
@ -341,13 +334,14 @@ describe("Inbox Tests", () => {
|
|||
});
|
||||
|
||||
const signedRequest = await sign(
|
||||
privateKey,
|
||||
new URL(exampleRequest.data.author),
|
||||
instanceKeys.privateKey,
|
||||
instanceUrl,
|
||||
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 +358,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 +399,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,
|
||||
instanceUrl,
|
||||
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 +439,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();
|
||||
});
|
||||
|
|
@ -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",
|
||||
"/.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,
|
||||
88
packages/api/routes/versia/v0.6/instance.ts
vendored
Normal file
88
packages/api/routes/versia/v0.6/instance.ts
vendored
Normal file
|
|
@ -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<typeof ImageContentFormatSchema>)
|
||||
: undefined,
|
||||
logo: config.instance.branding.logo
|
||||
? (urlToContentFormat(
|
||||
config.instance.branding.logo,
|
||||
) as z.infer<typeof ImageContentFormatSchema>)
|
||||
: undefined,
|
||||
created_at:
|
||||
firstUser?.data.createdAt.toISOString() ||
|
||||
"1970-01-01T00:00:00Z",
|
||||
} satisfies z.infer<typeof InstanceMetadataSchema>,
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof Instances> {
|
|||
}
|
||||
}
|
||||
|
||||
public static get federationRequester(): FederationRequester {
|
||||
return new FederationRequester(
|
||||
config.instance.keys.private,
|
||||
config.http.base_url,
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromUser(user: User): Promise<Instance | null> {
|
||||
if (!user.data.instanceId) {
|
||||
return null;
|
||||
|
|
@ -139,29 +148,24 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
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<typeof Instances> {
|
|||
}
|
||||
|
||||
private static async fetchActivityPubMetadata(
|
||||
url: URL,
|
||||
domain: string,
|
||||
): Promise<VersiaEntities.InstanceMetadata | null> {
|
||||
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<typeof Instances> {
|
|||
key: "",
|
||||
algorithm: "ed25519",
|
||||
},
|
||||
host: new URL(url).host,
|
||||
domain: origin.hostname,
|
||||
compatibility: {
|
||||
extensions: [],
|
||||
versions: [],
|
||||
|
|
@ -268,50 +272,33 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
}
|
||||
}
|
||||
|
||||
public static resolveFromHost(host: string): Promise<Instance> {
|
||||
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<Instance> {
|
||||
const host = url.host;
|
||||
|
||||
public static async resolve(domain: string): Promise<Instance> {
|
||||
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<Instance> {
|
||||
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<typeof Instances> {
|
|||
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<void> {
|
||||
if (
|
||||
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint
|
||||
|
|
|
|||
|
|
@ -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<typeof Likes> & {
|
||||
liker: InferSelectModel<typeof Users>;
|
||||
liked: InferSelectModel<typeof Notes>;
|
||||
liked: InferSelectModel<typeof Notes> & {
|
||||
author: InferSelectModel<typeof Users> & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||
|
|
@ -57,7 +62,15 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
where: sql,
|
||||
orderBy,
|
||||
with: {
|
||||
liked: true,
|
||||
liked: {
|
||||
with: {
|
||||
author: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liker: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -65,6 +78,7 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Like(found);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +87,6 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
orderBy: SQL<unknown> | undefined = desc(Likes.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Likes.findMany>[0],
|
||||
): Promise<Like[]> {
|
||||
const found = await db.query.Likes.findMany({
|
||||
where: sql,
|
||||
|
|
@ -81,9 +94,16 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
limit,
|
||||
offset,
|
||||
with: {
|
||||
liked: true,
|
||||
liked: {
|
||||
with: {
|
||||
author: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
liker: true,
|
||||
...extra?.with,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -146,37 +166,28 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -476,9 +476,7 @@ export class Media extends BaseInterface<typeof Medias> {
|
|||
[file.type]: {
|
||||
content: uri.toString(),
|
||||
remote: true,
|
||||
hash: {
|
||||
sha256: hash,
|
||||
},
|
||||
hash,
|
||||
width,
|
||||
height,
|
||||
description: options?.description,
|
||||
|
|
|
|||
|
|
@ -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<typeof Users> & {
|
||||
instance: typeof Instance.$type | null;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
quote:
|
||||
| (NoteType & {
|
||||
author: InferSelectModel<typeof Users> & {
|
||||
instance: typeof Instance.$type | null;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
client: typeof Client.$type | null;
|
||||
pinned: boolean;
|
||||
reblogged: boolean;
|
||||
|
|
@ -404,6 +424,17 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
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<void> {
|
||||
const users = await this.getUsersToFederateTo();
|
||||
|
||||
|
|
@ -489,13 +520,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
* 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<typeof StatusSchema.shape.visibility>,
|
||||
uri?: URL,
|
||||
remoteId?: string,
|
||||
): Promise<Note> {
|
||||
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<typeof Notes, NoteTypeWithRelations> {
|
|||
sensitive: false,
|
||||
updatedAt: new Date(),
|
||||
clientId: null,
|
||||
uri: uri?.href,
|
||||
remoteId,
|
||||
});
|
||||
|
||||
await this.recalculateReblogCount();
|
||||
|
|
@ -612,10 +643,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
*
|
||||
* 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<Like> {
|
||||
public async like(liker: User, remoteId?: string): Promise<Like> {
|
||||
// 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<typeof Notes, NoteTypeWithRelations> {
|
|||
id: randomUUIDv7(),
|
||||
likerId: liker.id,
|
||||
likedId: this.id,
|
||||
uri: uri?.href,
|
||||
remoteId,
|
||||
});
|
||||
|
||||
await this.recalculateLikeCount();
|
||||
|
|
@ -904,73 +935,107 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<Note | null> {
|
||||
public static async resolve(
|
||||
reference: VersiaEntities.Reference,
|
||||
defaultInstance?: Instance,
|
||||
): Promise<Note | null> {
|
||||
// Check if note not already in database
|
||||
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
|
||||
if (
|
||||
!(reference.domain || defaultInstance) ||
|
||||
reference.domain === config.http.base_url.hostname
|
||||
) {
|
||||
return await Note.fromId(reference.id);
|
||||
}
|
||||
|
||||
const instance = reference.domain
|
||||
? await Instance.resolve(reference.domain)
|
||||
: (defaultInstance as Instance);
|
||||
|
||||
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 Note.fromVersia(
|
||||
reference.domain
|
||||
? reference
|
||||
: new VersiaEntities.Reference(
|
||||
reference.id,
|
||||
instance.data.baseUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await Note.fromId(noteUuid[0]);
|
||||
}
|
||||
|
||||
return Note.fromVersia(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Promise<Note> {
|
||||
if (versiaNote instanceof URL) {
|
||||
// No bridge support for notes yet
|
||||
const note = await new FederationRequester(
|
||||
config.instance.keys.private,
|
||||
config.http.base_url,
|
||||
).fetchEntity(versiaNote, VersiaEntities.Note);
|
||||
versiaNote: VersiaEntities.Note,
|
||||
instance: Instance,
|
||||
): Promise<Note>;
|
||||
|
||||
return Note.fromVersia(note);
|
||||
public static async fromVersia(
|
||||
reference: VersiaEntities.Reference,
|
||||
): Promise<Note>;
|
||||
|
||||
public static async fromVersia(
|
||||
versiaNote: VersiaEntities.Note | VersiaEntities.Reference,
|
||||
instance?: Instance,
|
||||
): Promise<Note> {
|
||||
if (versiaNote instanceof VersiaEntities.Reference) {
|
||||
if (!versiaNote.domain) {
|
||||
throw new Error(
|
||||
"Cannot fetch Versia note from reference without domain",
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
// No bridge support for notes yet
|
||||
const note = await Instance.federationRequester.fetchEntity(
|
||||
versiaNote,
|
||||
VersiaEntities.Note,
|
||||
);
|
||||
|
||||
const instance = await Instance.resolve(versiaNote.domain);
|
||||
|
||||
return Note.fromVersia(note, instance);
|
||||
}
|
||||
|
||||
if (!instance) {
|
||||
throw new Error("Instance must be provided when fetching note");
|
||||
}
|
||||
|
||||
const { created_at, extensions, group, id, is_sensitive, subject } =
|
||||
versiaNote.data;
|
||||
|
||||
const author = await User.resolve(versiaNote.author, instance);
|
||||
|
||||
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 +1043,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
id: randomUUIDv7(),
|
||||
authorId: author.id,
|
||||
visibility: "public",
|
||||
uri,
|
||||
remoteId: id,
|
||||
createdAt: new Date(created_at),
|
||||
}));
|
||||
|
||||
|
|
@ -999,9 +1064,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
|
||||
const mentions = (
|
||||
await Promise.all(
|
||||
noteMentions?.map((mention) =>
|
||||
User.resolve(new URL(mention)),
|
||||
) ?? [],
|
||||
versiaNote.mentions.map((m) => User.resolve(m, instance)) ?? [],
|
||||
)
|
||||
).filter((m) => m !== null);
|
||||
|
||||
|
|
@ -1011,10 +1074,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
? "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, instance)
|
||||
: null;
|
||||
const quote = versiaNote.quotes
|
||||
? await Note.resolve(versiaNote.quotes, instance)
|
||||
: null;
|
||||
const quote = quotes ? await Note.resolve(new URL(quotes)) : null;
|
||||
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
|
||||
|
||||
await note.update({
|
||||
|
|
@ -1169,9 +1234,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
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 +1258,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
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 +1274,14 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
}
|
||||
|
||||
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 +1296,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
}
|
||||
|
||||
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 +1311,24 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
*/
|
||||
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 +1339,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
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 +1347,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
>,
|
||||
),
|
||||
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 +1375,22 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof Reactions> & {
|
||||
emoji: typeof Emoji.$type | null;
|
||||
author: InferSelectModel<typeof Users>;
|
||||
note: InferSelectModel<typeof Notes>;
|
||||
note: InferSelectModel<typeof Notes> & {
|
||||
author: InferSelectModel<typeof Users> & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||
|
|
@ -64,7 +72,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
},
|
||||
},
|
||||
author: true,
|
||||
note: true,
|
||||
note: {
|
||||
with: {
|
||||
author: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
});
|
||||
|
|
@ -96,7 +112,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
},
|
||||
},
|
||||
author: true,
|
||||
note: true,
|
||||
note: {
|
||||
with: {
|
||||
author: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -210,19 +234,18 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
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<typeof Reactions, ReactionType> {
|
|||
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<typeof Reactions, ReactionType> {
|
|||
|
||||
return Reaction.insert({
|
||||
id: randomUUIDv7(),
|
||||
uri: reactionToConvert.data.uri,
|
||||
remoteId: reactionToConvert.data.id,
|
||||
authorId: author.id,
|
||||
noteId: note.id,
|
||||
emojiId: emoji ? emoji.id : null,
|
||||
|
|
|
|||
|
|
@ -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<InferSelectModel<typeof Users>, "endpoints"> & {
|
||||
user: InferSelectModel<typeof Users> & {
|
||||
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<string, object>)
|
||||
|
|
@ -239,14 +222,26 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
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);
|
||||
}
|
||||
|
||||
public static getUri(id: string, uri: URL | null): URL {
|
||||
return uri ? uri : new URL(`/users/${id}`, config.http.base_url);
|
||||
return new VersiaEntities.Reference(
|
||||
this.data.remoteId as string,
|
||||
(this.data.instance as typeof Instance.$type).baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof Users, UserWithRelations> {
|
|||
}
|
||||
|
||||
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<typeof Users, UserWithRelations> {
|
|||
|
||||
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<typeof Users, UserWithRelations> {
|
|||
|
||||
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<typeof Users, UserWithRelations> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,55 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
* 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,
|
||||
instance: Instance,
|
||||
): Promise<User>;
|
||||
|
||||
public static async fromVersia(
|
||||
versiaUser: VersiaEntities.Reference,
|
||||
): Promise<User>;
|
||||
|
||||
public static async fromVersia(
|
||||
versiaUser: VersiaEntities.User | VersiaEntities.Reference,
|
||||
instance?: Instance,
|
||||
): Promise<User> {
|
||||
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);
|
||||
const instance = await Instance.resolve(versiaUser.domain);
|
||||
|
||||
return User.fromVersia(user, instance);
|
||||
}
|
||||
|
||||
if (!instance) {
|
||||
throw new Error("Instance must be provided when fetching user");
|
||||
}
|
||||
|
||||
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 existingUser = await User.fromSql(
|
||||
eq(Users.uri, versiaUser.data.uri),
|
||||
and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)),
|
||||
);
|
||||
|
||||
const user =
|
||||
|
|
@ -763,60 +726,50 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
(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,33 +800,51 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
return user;
|
||||
}
|
||||
|
||||
public static async resolve(uri: URL): Promise<User | null> {
|
||||
federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`;
|
||||
public static async resolve(
|
||||
reference: VersiaEntities.Reference,
|
||||
defaultInstance?: Instance,
|
||||
): Promise<User> {
|
||||
// Check if user not already in database
|
||||
const foundUser = await User.fromSql(eq(Users.uri, uri.href));
|
||||
if (
|
||||
!(reference.domain || defaultInstance) ||
|
||||
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 = reference.domain
|
||||
? await Instance.resolve(reference.domain)
|
||||
: (defaultInstance as Instance);
|
||||
|
||||
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 User.fromVersia(
|
||||
reference.domain
|
||||
? reference
|
||||
: new VersiaEntities.Reference(
|
||||
reference.id,
|
||||
instance.data.baseUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await User.fromId(userUuid[0]);
|
||||
}
|
||||
|
||||
federationResolversLogger.debug`User not found in database, fetching from remote`;
|
||||
|
||||
return User.fromVersia(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's avatar in raw URL format
|
||||
* @returns The raw URL for the user's avatar
|
||||
|
|
@ -890,31 +861,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
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 +870,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
isAdmin: boolean;
|
||||
}>,
|
||||
): Promise<User> {
|
||||
const keys = await User.generateKeys();
|
||||
|
||||
const user = await User.insert({
|
||||
id: randomUUIDv7(),
|
||||
username,
|
||||
|
|
@ -937,9 +881,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
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 +941,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
newUser.avatar ||
|
||||
newUser.header ||
|
||||
newUser.fields ||
|
||||
newUser.publicKey ||
|
||||
newUser.isAdmin ||
|
||||
newUser.isBot ||
|
||||
newUser.isLocked ||
|
||||
newUser.endpoints ||
|
||||
newUser.isDiscoverable ||
|
||||
newUser.isIndexable)
|
||||
) {
|
||||
|
|
@ -1013,20 +953,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
return updated.data;
|
||||
}
|
||||
|
||||
public get federationRequester(): Promise<FederationRequester> {
|
||||
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 +1002,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
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 +1032,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
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 +1091,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
return new VersiaEntities.User({
|
||||
id: user.id,
|
||||
type: "User",
|
||||
uri: this.uri.href,
|
||||
bio: {
|
||||
"text/html": {
|
||||
content: user.note,
|
||||
|
|
@ -1178,34 +1102,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
},
|
||||
},
|
||||
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 +1113,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
>,
|
||||
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) =>
|
||||
|
|
|
|||
|
|
@ -182,27 +182,36 @@ export class InboxProcessor {
|
|||
shouldCheckSignature && federationInboxLogger.debug`Signature is valid`;
|
||||
|
||||
try {
|
||||
// TODO: Rip out bridge code so this is never null
|
||||
const instance = this.sender?.instance as Instance;
|
||||
|
||||
await new EntitySorter(this.body)
|
||||
.on(VersiaEntities.Note, (n) => InboxProcessor.processNote(n))
|
||||
.on(VersiaEntities.Note, (n) =>
|
||||
InboxProcessor.processNote(n, instance),
|
||||
)
|
||||
.on(VersiaEntities.Follow, (f) =>
|
||||
InboxProcessor.processFollowRequest(f),
|
||||
InboxProcessor.processFollowRequest(f, instance),
|
||||
)
|
||||
.on(VersiaEntities.FollowAccept, (f) =>
|
||||
InboxProcessor.processFollowAccept(f),
|
||||
InboxProcessor.processFollowAccept(f, instance),
|
||||
)
|
||||
.on(VersiaEntities.FollowReject, (f) =>
|
||||
InboxProcessor.processFollowReject(f),
|
||||
InboxProcessor.processFollowReject(f, instance),
|
||||
)
|
||||
.on(VersiaEntities.Like, (l) =>
|
||||
InboxProcessor.processLikeRequest(l),
|
||||
InboxProcessor.processLikeRequest(l, instance),
|
||||
)
|
||||
.on(VersiaEntities.Delete, (d) =>
|
||||
InboxProcessor.processDelete(d),
|
||||
InboxProcessor.processDelete(d, instance),
|
||||
)
|
||||
.on(VersiaEntities.User, (u) =>
|
||||
InboxProcessor.processUser(u, instance),
|
||||
)
|
||||
.on(VersiaEntities.Share, (s) =>
|
||||
InboxProcessor.processShare(s, instance),
|
||||
)
|
||||
.on(VersiaEntities.User, (u) => InboxProcessor.processUser(u))
|
||||
.on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s))
|
||||
.on(VersiaEntities.Reaction, (r) =>
|
||||
InboxProcessor.processReaction(r),
|
||||
InboxProcessor.processReaction(r, instance),
|
||||
)
|
||||
.sort(() => {
|
||||
throw new ApiError(400, "Unknown entity type");
|
||||
|
|
@ -220,9 +229,10 @@ export class InboxProcessor {
|
|||
*/
|
||||
private static async processReaction(
|
||||
reaction: VersiaEntities.Reaction,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
const note = await Note.resolve(reaction.object, sender);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -241,7 +251,10 @@ export class InboxProcessor {
|
|||
* @param {VersiaNote} note - The Note entity to process.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private static async processNote(note: VersiaEntities.Note): Promise<void> {
|
||||
private static async processNote(
|
||||
note: VersiaEntities.Note,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
// If note has a blocked word
|
||||
if (
|
||||
Object.values(note.content?.data ?? {})
|
||||
|
|
@ -257,16 +270,20 @@ export class InboxProcessor {
|
|||
return;
|
||||
}
|
||||
|
||||
await Note.fromVersia(note);
|
||||
await Note.fromVersia(note, sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles User entity processing.
|
||||
*
|
||||
* @param {VersiaUser} user - The User entity to process.
|
||||
* @param {string} domain - The domain of the user.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private static async processUser(user: VersiaEntities.User): Promise<void> {
|
||||
private static async processUser(
|
||||
user: VersiaEntities.User,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
if (
|
||||
config.validation.filters.username.some((filter) =>
|
||||
filter.test(user.data.username),
|
||||
|
|
@ -294,7 +311,7 @@ export class InboxProcessor {
|
|||
return;
|
||||
}
|
||||
|
||||
await User.fromVersia(user);
|
||||
await User.fromVersia(user, sender);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -305,9 +322,10 @@ export class InboxProcessor {
|
|||
*/
|
||||
private static async processFollowRequest(
|
||||
follow: VersiaEntities.Follow,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
const followee = await User.resolve(follow.followee, sender);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -353,11 +371,10 @@ export class InboxProcessor {
|
|||
*/
|
||||
private static async processFollowAccept(
|
||||
followAccept: VersiaEntities.FollowAccept,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
const follower = await User.resolve(followAccept.follower, sender);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -390,11 +407,10 @@ export class InboxProcessor {
|
|||
*/
|
||||
private static async processFollowReject(
|
||||
followReject: VersiaEntities.FollowReject,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
const follower = await User.resolve(followReject.follower, sender);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -427,9 +443,10 @@ export class InboxProcessor {
|
|||
*/
|
||||
private static async processShare(
|
||||
share: VersiaEntities.Share,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
const sharedNote = await Note.resolve(share.shared, sender);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -439,7 +456,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -450,18 +467,17 @@ export class InboxProcessor {
|
|||
*/ // JS doesn't allow the use of `delete` as a variable name
|
||||
public static async processDelete(
|
||||
delete_: VersiaEntities.Delete,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
|
||||
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 +491,7 @@ export class InboxProcessor {
|
|||
return;
|
||||
}
|
||||
case "User": {
|
||||
const userToDelete = await User.resolve(new URL(toDelete));
|
||||
const userToDelete = await User.resolve(toDelete, sender);
|
||||
|
||||
if (!userToDelete) {
|
||||
throw new ApiError(404, "User to delete not found");
|
||||
|
|
@ -490,8 +506,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 +541,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) {
|
||||
|
|
@ -567,9 +586,10 @@ export class InboxProcessor {
|
|||
*/
|
||||
private static async processLikeRequest(
|
||||
like: VersiaEntities.Like,
|
||||
sender: Instance,
|
||||
): Promise<void> {
|
||||
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, sender);
|
||||
const likedNote = await Note.resolve(like.liked, sender);
|
||||
|
||||
if (!author) {
|
||||
throw new ApiError(404, "Author not found");
|
||||
|
|
@ -579,7 +599,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,14 @@ export const parseMentionsFromText = async (text: string): Promise<User[]> => {
|
|||
);
|
||||
|
||||
if (url) {
|
||||
const user = await User.resolve(url);
|
||||
const userEntity = await Instance.federationRequester.fetchSigned(
|
||||
url,
|
||||
VersiaEntities.User,
|
||||
);
|
||||
|
||||
const instance = await Instance.resolve(url.hostname);
|
||||
|
||||
const user = await User.fromVersia(userEntity, instance);
|
||||
|
||||
if (user) {
|
||||
finalList.push(user);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
|
|||
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<FetchJobData, void, FetchJobType> =>
|
|||
return;
|
||||
}
|
||||
|
||||
await Instance.resolve(new URL(uri));
|
||||
await Instance.resolve(host);
|
||||
|
||||
await job.log(
|
||||
`✔ Finished fetching instance metadata from [${uri}]`,
|
||||
|
|
|
|||
|
|
@ -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<InboxJobData, void, InboxJobType> =>
|
|||
"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<InboxJobData, void, InboxJobType> =>
|
|||
req,
|
||||
data,
|
||||
{
|
||||
instance: remoteInstance,
|
||||
instance: sender,
|
||||
key,
|
||||
},
|
||||
undefined,
|
||||
|
|
@ -147,10 +124,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
|||
);
|
||||
|
||||
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(
|
||||
|
|
|
|||
14
packages/kit/tables/migrations/0054_good_madelyne_pryor.sql
Normal file
14
packages/kit/tables/migrations/0054_good_madelyne_pryor.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
ALTER TABLE "Likes" DROP CONSTRAINT "Likes_uri_unique";--> statement-breakpoint
|
||||
ALTER TABLE "Notes" DROP CONSTRAINT "Notes_uri_unique";--> statement-breakpoint
|
||||
ALTER TABLE "Users" DROP CONSTRAINT "Users_uri_unique";--> statement-breakpoint
|
||||
DROP INDEX "Users_uri_index";--> statement-breakpoint
|
||||
ALTER TABLE "Likes" ADD COLUMN "remote_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "Notes" ADD COLUMN "remote_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "Reaction" ADD COLUMN "remote_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "Users" ADD COLUMN "remote_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "Likes" DROP COLUMN "uri";--> statement-breakpoint
|
||||
ALTER TABLE "Notes" DROP COLUMN "uri";--> statement-breakpoint
|
||||
ALTER TABLE "Users" DROP COLUMN "uri";--> statement-breakpoint
|
||||
ALTER TABLE "Users" DROP COLUMN "endpoints";--> statement-breakpoint
|
||||
ALTER TABLE "Users" DROP COLUMN "public_key";--> statement-breakpoint
|
||||
ALTER TABLE "Users" DROP COLUMN "private_key";
|
||||
2394
packages/kit/tables/migrations/meta/0054_snapshot.json
Normal file
2394
packages/kit/tables/migrations/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -379,6 +379,13 @@
|
|||
"when": 1765422160004,
|
||||
"tag": "0053_lively_hellfire_club",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "7",
|
||||
"when": 1771983340896,
|
||||
"tag": "0054_good_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof TextContentFormatSchema>;
|
||||
}[]
|
||||
>(),
|
||||
endpoints: jsonb("endpoints").$type<Partial<{
|
||||
dislikes?: string;
|
||||
featured: string;
|
||||
likes?: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
}> | null>(),
|
||||
source: jsonb("source").$type<z.infer<typeof Source>>(),
|
||||
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 }) => ({
|
||||
|
|
|
|||
|
|
@ -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<Request> => {
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Delete> {
|
||||
return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Like> {
|
||||
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<Dislike> {
|
||||
return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vote> {
|
||||
return VoteSchema.parseAsync(json).then((u) => new Vote(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Reaction> {
|
||||
return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Report> {
|
||||
return ReportSchema.parseAsync(json).then((u) => new Report(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Share> {
|
||||
return ShareSchema.parseAsync(json).then((u) => new Share(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Follow> {
|
||||
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<FollowAccept> {
|
||||
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<FollowReject> {
|
||||
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<Unfollow> {
|
||||
return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)) ?? []
|
||||
|
|
|
|||
|
|
@ -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<T extends typeof Entity>(
|
||||
public async fetchSigned<T extends typeof Entity>(
|
||||
url: URL,
|
||||
expectedType: T,
|
||||
entityType: T,
|
||||
): Promise<InstanceType<T>> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
public async postEntity(url: URL, entity: Entity): Promise<Response> {
|
||||
public fetchEntity<T extends typeof Entity>(
|
||||
reference: Reference,
|
||||
entityType: T,
|
||||
): Promise<InstanceType<T>> {
|
||||
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<Response> {
|
||||
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<T extends typeof Entity>(
|
||||
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<InstanceType<T>[]> {
|
||||
const url = new URL(
|
||||
`/.versia/v0.6/entities/${encodeURIComponent(
|
||||
entityType.name,
|
||||
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
|
||||
collectionName,
|
||||
)}`,
|
||||
`https://${reference.domain}`,
|
||||
);
|
||||
|
||||
const entities: InstanceType<T>[] = [];
|
||||
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) {
|
||||
while (collection && limit > 0) {
|
||||
entities.push(
|
||||
(await expectedType.fromJSON(
|
||||
entity,
|
||||
)) as InstanceType<T>,
|
||||
...collection.data.items.map(
|
||||
(item) =>
|
||||
collectionItemType.fromJSON(item) as InstanceType<T>,
|
||||
),
|
||||
);
|
||||
}
|
||||
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<E extends typeof Entity>(
|
||||
reference: Reference,
|
||||
collectionName: string,
|
||||
entityType: E,
|
||||
options?: {
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<URL[]> {
|
||||
const entities: string[] = [];
|
||||
let nextUrl: URL | null = url;
|
||||
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
while (nextUrl && limit > 0) {
|
||||
const collection: URICollection = await this.fetchEntity(
|
||||
nextUrl,
|
||||
URICollection,
|
||||
): Promise<string[]> {
|
||||
const url = new URL(
|
||||
`/.versia/v0.6/entities/${encodeURIComponent(
|
||||
entityType.name,
|
||||
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
|
||||
collectionName,
|
||||
)}`,
|
||||
`https://${reference.domain}`,
|
||||
);
|
||||
|
||||
entities.push(...collection.data.items);
|
||||
nextUrl = collection.data.next
|
||||
? new URL(collection.data.next)
|
||||
: null;
|
||||
const uris: string[] = [];
|
||||
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
let collection = await this.fetchSigned(url, URICollection);
|
||||
const total = collection.data.total;
|
||||
|
||||
while (collection && limit > 0) {
|
||||
uris.push(...collection.data.items);
|
||||
limit -= collection.data.items.length;
|
||||
|
||||
if (uris.length >= total) {
|
||||
break;
|
||||
}
|
||||
|
||||
return entities.map((u) => new URL(u));
|
||||
url.searchParams.set("offset", uris.length.toString());
|
||||
collection = await this.fetchSigned(url, URICollection);
|
||||
}
|
||||
|
||||
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<URL | null> {
|
||||
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<InstanceMetadata> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
".": "./inbox-processor.ts",
|
||||
"./http": "./http.ts",
|
||||
"./crypto": "./crypto.ts",
|
||||
"./entities": "./entities.ts",
|
||||
"./schemas": "./schemas.ts"
|
||||
"./entities": "./entities/index.ts",
|
||||
"./schemas": "./schemas/index.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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,7 +21,6 @@ export const VanityExtensionSchema = z.strictObject({
|
|||
pronouns: z.record(
|
||||
z.string(),
|
||||
z.array(
|
||||
z.union([
|
||||
z.strictObject({
|
||||
subject: z.string(),
|
||||
object: z.string(),
|
||||
|
|
@ -29,16 +28,14 @@ export const VanityExtensionSchema = z.strictObject({
|
|||
independent_possessive: z.string(),
|
||||
reflexive: z.string(),
|
||||
}),
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,11 +30,10 @@ 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(
|
||||
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(),
|
||||
|
|
@ -52,10 +41,9 @@ export const NoteSchema = EntitySchema.extend({
|
|||
image: url.nullish(),
|
||||
icon: url.nullish(),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
quotes: url.nullish(),
|
||||
replies_to: url.nullish(),
|
||||
),
|
||||
quotes: ReferenceSchema.nullish(),
|
||||
replies_to: ReferenceSchema.nullish(),
|
||||
subject: z.string().nullish(),
|
||||
extensions: EntitySchema.shape.extensions
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
fields: z.array(
|
||||
z.strictObject({
|
||||
key: TextContentFormatSchema,
|
||||
value: TextContentFormatSchema,
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
),
|
||||
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()
|
||||
|
|
|
|||
11
patches/bun-bagel@1.2.0.patch
Normal file
11
patches/bun-bagel@1.2.0.patch
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 801380e3811704fc199b80f884ec3ff87d5116f1..381de429cae24d254609c097dad8b52d8865c2cb 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -1,5 +1,5 @@
|
||||
// @bun
|
||||
-var $={method:"GET",data:null,headers:new Headers,response:{data:null,headers:new Headers,status:200}};function Q(z){let X=`${z.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/\\\*/g,"[\\s\\S]*")}$`;return new RegExp(X)}var f=(z)=>(W)=>{let[J,X]=z,[V,Y]=W;if(!(J.toString()===V.toString()||J.match(V)))return!1;let x=X?.method||"GET",H=Y?.method||"GET";if(x.toLowerCase()!==H.toLowerCase())return!1;let G=new Headers(X?.headers),b=new Headers(Y?.headers),K=[...G.keys(),...b.keys()];for(let y of K){let N=G.get(y),g=b.get(y);if(N!==g)return!1}return!0},v=(z,W=$)=>{let{headers:J,data:X,response:V}=W,Y=V?.data??X,w=V?.headers??J,x=Y instanceof Blob||Y instanceof FormData?Y:new Blob([JSON.stringify(Y)]);return new Response(x,{headers:w,status:z})};var Z,P=!1,j=new Map,m=(z,W=$)=>{let J=z instanceof Request?z.url:z,X=J instanceof RegExp?J:new RegExp(Q(J.toString())),V=[...j.entries()].find(f([X.toString(),W]));if(process.env.VERBOSE){if(!V)console.debug("\x1B[1mRegistered mocked request\x1B[0m");else console.debug("\x1B[1mRequest already mocked\x1B[0m \x1B[2mupdated\x1B[0m");console.debug("\x1B[2mURL\x1B[0m",J),console.debug("\x1B[2mPath Pattern\x1B[0m",X),console.debug("\x1B[2mStatus\x1B[0m",W.response?.status||200),console.debug("\x1B[2mMethod\x1B[0m",W.method||"GET"),console.debug(`
|
||||
+var $={method:"GET",data:null,headers:new Headers,response:{data:null,headers:new Headers,status:200}};function Q(z){let X=`${z.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/\\\*/g,"[\\s\\S]*")}$`;return new RegExp(X)}var f=(z)=>(W)=>{let[J,X]=z,[V,Y]=W;if(!(J.toString()===V.toString()||J.match(V)))return!1;let x=X?.method||"GET",H=Y?.method||"GET";if(x.toLowerCase()!==H.toLowerCase())return!1;let G=new Headers(X?.headers),b=new Headers(Y?.headers),K=[...G.keys(),...b.keys()];for(let y of K){let N=G.get(y),g=b.get(y);if(g&&N!==g)return!1}return!0},v=(z,W=$)=>{let{headers:J,data:X,response:V}=W,Y=V?.data??X,w=V?.headers??J,x=Y instanceof Blob||Y instanceof FormData?Y:new Blob([JSON.stringify(Y)]);return new Response(x,{headers:w,status:z})};var Z,P=!1,j=new Map,m=(z,W=$)=>{let J=z instanceof Request?z.url:z,X=J instanceof RegExp?J:new RegExp(Q(J.toString())),V=[...j.entries()].find(f([X.toString(),W]));if(process.env.VERBOSE){if(!V)console.debug("\x1B[1mRegistered mocked request\x1B[0m");else console.debug("\x1B[1mRequest already mocked\x1B[0m \x1B[2mupdated\x1B[0m");console.debug("\x1B[2mURL\x1B[0m",J),console.debug("\x1B[2mPath Pattern\x1B[0m",X),console.debug("\x1B[2mStatus\x1B[0m",W.response?.status||200),console.debug("\x1B[2mMethod\x1B[0m",W.method||"GET"),console.debug(`
|
||||
`)}if(!V)j.set(X,W);else return;if(!Z)Z=globalThis.fetch,globalThis.fetch=F;return!0},h=()=>{if(j.clear(),Z)globalThis.fetch=Z.bind({}),Z=void 0},F=async(z,W)=>{let J=z instanceof Request?z.url:z.toString(),X=z instanceof Request?z:new Request(J),V=[...j.entries()].find(f([J,W]));if(!V){if(P)return Promise.reject(v(404));return await Z(X,W)}if(process.env.VERBOSE)console.debug("\x1B[2mMocked fetch called\x1B[0m",J);if(V[1].throw)throw V[1].throw;let Y=V[1].response?.status||200;return v(Y,V[1])},I=()=>{P=!1},O=()=>{P=!0};export{m as mock,I as enableRealRequests,O as disableRealRequests,h as clearMocks};
|
||||
|
||||
//# debugId=6C205C9DF1DC0DBE64756E2164756E21
|
||||
|
|
@ -29,6 +29,7 @@ export interface ApiRouteExports {
|
|||
}
|
||||
|
||||
export type KnownEntity =
|
||||
| VersiaEntities.Entity
|
||||
| VersiaEntities.Note
|
||||
| VersiaEntities.InstanceMetadata
|
||||
| VersiaEntities.User
|
||||
|
|
|
|||
|
|
@ -57,7 +57,12 @@ export const applyToHono = (app: Hono<HonoEnv>): void => {
|
|||
throw new ApiError(401, "Missing JWT cookie");
|
||||
}
|
||||
|
||||
const result = await verify(jwtCookie, config.authentication.key);
|
||||
const result = await verify(jwtCookie, config.authentication.key, {
|
||||
alg: "HS256",
|
||||
iss: config.http.base_url.toString(),
|
||||
}).catch(() => {
|
||||
throw new ApiError(401, "Invalid JWT");
|
||||
});
|
||||
|
||||
const { sub } = result;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue