mirror of
https://github.com/versia-pub/server.git
synced 2026-04-27 12:49:16 +02:00
feat(federation): ✨ Port to Versia 0.6
This commit is contained in:
parent
de69f27877
commit
fca30b4dad
62 changed files with 1614 additions and 2008 deletions
|
|
@ -28,7 +28,10 @@ export const refetchUserCommand = defineCommand(
|
||||||
const spinner = ora("Refetching user").start();
|
const spinner = ora("Refetching user").start();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await User.fromVersia(user.uri);
|
await User.fromVersia(
|
||||||
|
user.reference,
|
||||||
|
user.reference.domain as string,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spinner.fail(
|
spinner.fail(
|
||||||
`Failed to refetch user ${chalk.gray(user.data.username)}`,
|
`Failed to refetch user ${chalk.gray(user.data.username)}`,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const retrieveUser = async (
|
||||||
): Promise<User | null> => {
|
): Promise<User | null> => {
|
||||||
const { username, domain } = parseUserAddress(usernameOrHandle);
|
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(
|
const user = await User.fromSql(
|
||||||
and(
|
and(
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,10 @@ export default apiRoute((app) =>
|
||||||
throw new ApiError(400, "Cannot refetch a local user");
|
throw new ApiError(400, "Cannot refetch a local user");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUser = await User.fromVersia(otherUser.uri);
|
const newUser = await User.fromVersia(
|
||||||
|
otherUser.reference,
|
||||||
|
otherUser.reference.domain as string,
|
||||||
|
);
|
||||||
|
|
||||||
return context.json(newUser.toApi(false), 200);
|
return context.json(newUser.toApi(false), 200);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { config } from "@versia-server/config";
|
import { config } from "@versia-server/config";
|
||||||
import { ApiError } from "@versia-server/kit";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
||||||
|
|
@ -73,7 +74,7 @@ export default apiRoute((app) =>
|
||||||
|
|
||||||
// User is remote
|
// User is remote
|
||||||
// Try to fetch it from database
|
// Try to fetch it from database
|
||||||
const instance = await Instance.resolveFromHost(domain);
|
const instance = await Instance.resolve(domain);
|
||||||
|
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
return context.json(
|
return context.json(
|
||||||
|
|
@ -100,13 +101,17 @@ export default apiRoute((app) =>
|
||||||
throw ApiError.accountNotFound();
|
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.data.baseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
if (foundAccount) {
|
|
||||||
return context.json(foundAccount.toApi(), 200);
|
return context.json(foundAccount.toApi(), 200);
|
||||||
}
|
|
||||||
|
|
||||||
throw ApiError.accountNotFound();
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import {
|
||||||
RolePermission,
|
RolePermission,
|
||||||
zBoolean,
|
zBoolean,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { ApiError } from "@versia-server/kit";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
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 { parseUserAddress } from "@versia-server/kit/parsers";
|
||||||
import { Users } from "@versia-server/kit/tables";
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
||||||
|
|
@ -88,14 +89,22 @@ export default apiRoute((app) =>
|
||||||
const accounts: User[] = [];
|
const accounts: User[] = [];
|
||||||
|
|
||||||
if (resolve && domain) {
|
if (resolve && domain) {
|
||||||
|
const instance = await Instance.resolve(domain);
|
||||||
const uri = await User.webFinger(username, domain);
|
const uri = await User.webFinger(username, domain);
|
||||||
|
|
||||||
if (uri) {
|
if (uri) {
|
||||||
const resolvedUser = await User.resolve(uri);
|
const accountData =
|
||||||
|
await Instance.federationRequester.fetchSigned(
|
||||||
|
uri,
|
||||||
|
VersiaEntities.User,
|
||||||
|
);
|
||||||
|
|
||||||
if (resolvedUser) {
|
const foundAccount = await User.fromVersia(
|
||||||
accounts.push(resolvedUser);
|
accountData,
|
||||||
}
|
instance.data.baseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
accounts.push(foundAccount);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
accounts.push(
|
accounts.push(
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import {
|
||||||
userAddressRegex,
|
userAddressRegex,
|
||||||
zBoolean,
|
zBoolean,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { config } from "@versia-server/config";
|
import { config } from "@versia-server/config";
|
||||||
import { ApiError } from "@versia-server/kit";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
|
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 { parseUserAddress } from "@versia-server/kit/parsers";
|
||||||
import { searchManager } from "@versia-server/kit/search";
|
import { searchManager } from "@versia-server/kit/search";
|
||||||
import { Instances, Notes, Users } from "@versia-server/kit/tables";
|
import { Instances, Notes, Users } from "@versia-server/kit/tables";
|
||||||
|
|
@ -187,12 +188,21 @@ export default apiRoute((app) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolve && domain) {
|
if (resolve && domain) {
|
||||||
|
const instance = await Instance.resolve(domain);
|
||||||
const uri = await User.webFinger(username, domain);
|
const uri = await User.webFinger(username, domain);
|
||||||
|
|
||||||
if (uri) {
|
if (uri) {
|
||||||
const newUser = await User.resolve(uri);
|
const accountData =
|
||||||
|
await Instance.federationRequester.fetchSigned(
|
||||||
|
uri,
|
||||||
|
VersiaEntities.User,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newUser = await User.fromVersia(
|
||||||
|
accountData,
|
||||||
|
instance.data.baseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
if (newUser) {
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
accounts: [newUser.toApi()],
|
accounts: [newUser.toApi()],
|
||||||
|
|
@ -204,7 +214,6 @@ export default apiRoute((app) =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
accountResults = await searchManager.searchAccounts(
|
accountResults = await searchManager.searchAccounts(
|
||||||
q,
|
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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
@ -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 shareId = randomUUIDv7();
|
||||||
const reactionId = randomUUIDv7();
|
const reactionId = randomUUIDv7();
|
||||||
const reaction2Id = randomUUIDv7();
|
const reaction2Id = randomUUIDv7();
|
||||||
const userKeys = await User.generateKeys();
|
|
||||||
const privateKey = await crypto.subtle.importKey(
|
const instanceKeys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||||
"pkcs8",
|
"sign",
|
||||||
Buffer.from(userKeys.private_key, "base64"),
|
"verify",
|
||||||
"Ed25519",
|
]);
|
||||||
false,
|
|
||||||
["sign"],
|
const inboxUrl = new URL("/.versia/v0.6/inbox", config.http.base_url);
|
||||||
);
|
|
||||||
const instanceKeys = await User.generateKeys();
|
|
||||||
const inboxUrl = new URL("/inbox", config.http.base_url);
|
|
||||||
const { users, deleteUsers } = await getTestUsers(1);
|
const { users, deleteUsers } = await getTestUsers(1);
|
||||||
|
|
||||||
disableRealRequests();
|
disableRealRequests();
|
||||||
|
|
@ -48,24 +45,29 @@ mock(new URL("/.well-known/versia", instanceUrl).href, {
|
||||||
name: "Versia",
|
name: "Versia",
|
||||||
description: "Versia instance",
|
description: "Versia instance",
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
host: instanceUrl.hostname,
|
domain: instanceUrl.hostname,
|
||||||
software: {
|
software: {
|
||||||
name: "Versia",
|
name: "Versia",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
compatibility: {
|
compatibility: {
|
||||||
extensions: [],
|
extensions: [],
|
||||||
versions: ["0.5.0"],
|
versions: ["0.6.0"],
|
||||||
},
|
},
|
||||||
public_key: {
|
public_key: {
|
||||||
algorithm: "ed25519",
|
algorithm: "ed25519",
|
||||||
key: instanceKeys.public_key,
|
key: Buffer.from(
|
||||||
|
await crypto.subtle.exportKey(
|
||||||
|
"spki",
|
||||||
|
instanceKeys.publicKey,
|
||||||
|
),
|
||||||
|
).toString("base64"),
|
||||||
},
|
},
|
||||||
}).toJSON(),
|
}).toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mock(new URL(`/users/${userId}`, instanceUrl).href, {
|
mock(new URL(`/.versia/v0.6/entities/User/${userId}`, instanceUrl).href, {
|
||||||
response: {
|
response: {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -73,31 +75,18 @@ mock(new URL(`/users/${userId}`, instanceUrl).href, {
|
||||||
data: new VersiaEntities.User({
|
data: new VersiaEntities.User({
|
||||||
id: userId,
|
id: userId,
|
||||||
created_at: "2025-04-18T10:32:01.427Z",
|
created_at: "2025-04-18T10:32:01.427Z",
|
||||||
uri: new URL(`/users/${userId}`, instanceUrl).href,
|
|
||||||
type: "User",
|
type: "User",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
public_key: {
|
fields: [],
|
||||||
algorithm: "ed25519",
|
manually_approves_followers: false,
|
||||||
key: userKeys.public_key,
|
indexable: true,
|
||||||
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,
|
|
||||||
},
|
|
||||||
}).toJSON(),
|
}).toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Delete the instance in database
|
// Delete the instance in database
|
||||||
const instance = await Instance.resolve(instanceUrl);
|
const instance = await Instance.resolve(instanceUrl.hostname);
|
||||||
|
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
throw new Error("Instance not found");
|
throw new Error("Instance not found");
|
||||||
|
|
@ -111,18 +100,18 @@ afterAll(async () => {
|
||||||
|
|
||||||
describe("Inbox Tests", () => {
|
describe("Inbox Tests", () => {
|
||||||
test("should correctly process inbox request", async () => {
|
test("should correctly process inbox request", async () => {
|
||||||
const exampleRequest = new VersiaEntities.Note({
|
const exampleNote = new VersiaEntities.Note({
|
||||||
id: noteId,
|
id: noteId,
|
||||||
created_at: "2025-04-18T10:32:01.427Z",
|
created_at: "2025-04-18T10:32:01.427Z",
|
||||||
uri: new URL(`/notes/${noteId}`, instanceUrl).href,
|
|
||||||
type: "Note",
|
type: "Note",
|
||||||
extensions: {
|
extensions: {
|
||||||
"pub.versia:custom_emojis": {
|
"pub.versia:custom_emojis": {
|
||||||
emojis: [],
|
emojis: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
previews: [],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
author: userId,
|
||||||
content: {
|
content: {
|
||||||
"text/html": {
|
"text/html": {
|
||||||
content: "<p>Hello!</p>",
|
content: "<p>Hello!</p>",
|
||||||
|
|
@ -133,10 +122,6 @@ describe("Inbox Tests", () => {
|
||||||
remote: false,
|
remote: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: {
|
|
||||||
replies: new URL(`/notes/${noteId}/replies`, instanceUrl).href,
|
|
||||||
quotes: new URL(`/notes/${noteId}/quotes`, instanceUrl).href,
|
|
||||||
},
|
|
||||||
group: "public",
|
group: "public",
|
||||||
is_sensitive: false,
|
is_sensitive: false,
|
||||||
mentions: [],
|
mentions: [],
|
||||||
|
|
@ -146,16 +131,17 @@ describe("Inbox Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const signedRequest = await sign(
|
const signedRequest = await sign(
|
||||||
privateKey,
|
instanceKeys.privateKey,
|
||||||
new URL(exampleRequest.data.author),
|
new URL(exampleNote.data.author),
|
||||||
new Request(inboxUrl, {
|
new Request(inboxUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type":
|
||||||
Accept: "application/json",
|
"application/vnd.versia+json; charset=utf-8",
|
||||||
|
Accept: "application/vnd.versia+json",
|
||||||
"User-Agent": "Versia/1.0.0",
|
"User-Agent": "Versia/1.0.0",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(exampleRequest.toJSON()),
|
body: JSON.stringify(exampleNote.toJSON()),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -170,7 +156,9 @@ describe("Inbox Tests", () => {
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
// Check if note was created in the database
|
// 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();
|
expect(note).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -179,20 +167,20 @@ describe("Inbox Tests", () => {
|
||||||
const exampleRequest = new VersiaEntities.Share({
|
const exampleRequest = new VersiaEntities.Share({
|
||||||
id: shareId,
|
id: shareId,
|
||||||
created_at: "2025-04-18T10:32:01.427Z",
|
created_at: "2025-04-18T10:32:01.427Z",
|
||||||
uri: new URL(`/shares/${shareId}`, instanceUrl).href,
|
|
||||||
type: "pub.versia:share/Share",
|
type: "pub.versia:share/Share",
|
||||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
author: userId,
|
||||||
shared: new URL(`/notes/${noteId}`, instanceUrl).href,
|
shared: noteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const signedRequest = await sign(
|
const signedRequest = await sign(
|
||||||
privateKey,
|
instanceKeys.privateKey,
|
||||||
new URL(exampleRequest.data.author),
|
new URL(exampleRequest.data.author),
|
||||||
new Request(inboxUrl, {
|
new Request(inboxUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type":
|
||||||
Accept: "application/json",
|
"application/vnd.versia+json; charset=utf-8",
|
||||||
|
Accept: "application/vnd.versia+json",
|
||||||
"User-Agent": "Versia/1.0.0",
|
"User-Agent": "Versia/1.0.0",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(exampleRequest.toJSON()),
|
body: JSON.stringify(exampleRequest.toJSON()),
|
||||||
|
|
@ -209,9 +197,7 @@ describe("Inbox Tests", () => {
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const dbNote = await Note.fromSql(
|
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!dbNote) {
|
if (!dbNote) {
|
||||||
throw new Error("DBNote not found");
|
throw new Error("DBNote not found");
|
||||||
|
|
@ -221,6 +207,7 @@ describe("Inbox Tests", () => {
|
||||||
const share = await Note.fromSql(
|
const share = await Note.fromSql(
|
||||||
and(
|
and(
|
||||||
eq(Notes.reblogId, dbNote.id),
|
eq(Notes.reblogId, dbNote.id),
|
||||||
|
eq(Notes.remoteId, shareId),
|
||||||
eq(Notes.authorId, dbNote.data.authorId),
|
eq(Notes.authorId, dbNote.data.authorId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -232,21 +219,21 @@ describe("Inbox Tests", () => {
|
||||||
const exampleRequest = new VersiaEntities.Reaction({
|
const exampleRequest = new VersiaEntities.Reaction({
|
||||||
id: reactionId,
|
id: reactionId,
|
||||||
created_at: "2025-04-18T10:32:01.427Z",
|
created_at: "2025-04-18T10:32:01.427Z",
|
||||||
uri: new URL(`/reactions/${reactionId}`, instanceUrl).href,
|
|
||||||
type: "pub.versia:reactions/Reaction",
|
type: "pub.versia:reactions/Reaction",
|
||||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
author: userId,
|
||||||
object: new URL(`/notes/${noteId}`, instanceUrl).href,
|
object: noteId,
|
||||||
content: "👍",
|
content: "👍",
|
||||||
});
|
});
|
||||||
|
|
||||||
const signedRequest = await sign(
|
const signedRequest = await sign(
|
||||||
privateKey,
|
instanceKeys.privateKey,
|
||||||
new URL(exampleRequest.data.author),
|
new URL(exampleRequest.data.author),
|
||||||
new Request(inboxUrl, {
|
new Request(inboxUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type":
|
||||||
Accept: "application/json",
|
"application/vnd.versia+json; charset=utf-8",
|
||||||
|
Accept: "application/vnd.versia+json",
|
||||||
"User-Agent": "Versia/1.0.0",
|
"User-Agent": "Versia/1.0.0",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(exampleRequest.toJSON()),
|
body: JSON.stringify(exampleRequest.toJSON()),
|
||||||
|
|
@ -263,18 +250,14 @@ describe("Inbox Tests", () => {
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const dbNote = await Note.fromSql(
|
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!dbNote) {
|
if (!dbNote) {
|
||||||
throw new Error("DBNote not found");
|
throw new Error("DBNote not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the remote user who reacted by URI
|
// Find the remote user who reacted by URI
|
||||||
const remoteUser = await User.fromSql(
|
const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
|
||||||
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!remoteUser) {
|
if (!remoteUser) {
|
||||||
throw new Error("Remote user not found");
|
throw new Error("Remote user not found");
|
||||||
|
|
@ -311,10 +294,9 @@ describe("Inbox Tests", () => {
|
||||||
const exampleRequest = new VersiaEntities.Reaction({
|
const exampleRequest = new VersiaEntities.Reaction({
|
||||||
id: reaction2Id,
|
id: reaction2Id,
|
||||||
created_at: "2025-04-18T10:32:01.427Z",
|
created_at: "2025-04-18T10:32:01.427Z",
|
||||||
uri: new URL(`/reactions/${reaction2Id}`, instanceUrl).href,
|
|
||||||
type: "pub.versia:reactions/Reaction",
|
type: "pub.versia:reactions/Reaction",
|
||||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
author: userId,
|
||||||
object: new URL(`/notes/${noteId}`, instanceUrl).href,
|
object: noteId,
|
||||||
content: ":neocat:",
|
content: ":neocat:",
|
||||||
extensions: {
|
extensions: {
|
||||||
"pub.versia:custom_emojis": {
|
"pub.versia:custom_emojis": {
|
||||||
|
|
@ -323,9 +305,7 @@ describe("Inbox Tests", () => {
|
||||||
name: ":neocat:",
|
name: ":neocat:",
|
||||||
url: {
|
url: {
|
||||||
"image/webp": {
|
"image/webp": {
|
||||||
hash: {
|
hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
|
||||||
sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
|
|
||||||
},
|
|
||||||
size: 4664,
|
size: 4664,
|
||||||
width: 256,
|
width: 256,
|
||||||
height: 256,
|
height: 256,
|
||||||
|
|
@ -341,13 +321,14 @@ describe("Inbox Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const signedRequest = await sign(
|
const signedRequest = await sign(
|
||||||
privateKey,
|
instanceKeys.privateKey,
|
||||||
new URL(exampleRequest.data.author),
|
new URL(exampleRequest.data.author),
|
||||||
new Request(inboxUrl, {
|
new Request(inboxUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type":
|
||||||
Accept: "application/json",
|
"application/vnd.versia+json; charset=utf-8",
|
||||||
|
Accept: "application/vnd.versia+json",
|
||||||
"User-Agent": "Versia/1.0.0",
|
"User-Agent": "Versia/1.0.0",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(exampleRequest.toJSON()),
|
body: JSON.stringify(exampleRequest.toJSON()),
|
||||||
|
|
@ -364,18 +345,14 @@ describe("Inbox Tests", () => {
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const dbNote = await Note.fromSql(
|
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!dbNote) {
|
if (!dbNote) {
|
||||||
throw new Error("DBNote not found");
|
throw new Error("DBNote not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the remote user who reacted by URI
|
// Find the remote user who reacted by URI
|
||||||
const remoteUser = await User.fromSql(
|
const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
|
||||||
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!remoteUser) {
|
if (!remoteUser) {
|
||||||
throw new Error("Remote user not found");
|
throw new Error("Remote user not found");
|
||||||
|
|
@ -409,36 +386,29 @@ describe("Inbox Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should correctly process Delete", async () => {
|
test("should correctly process Delete", async () => {
|
||||||
const deleteId = randomUUIDv7();
|
|
||||||
|
|
||||||
// First check that the note exists in the database
|
// First check that the note exists in the database
|
||||||
const noteToDelete = await Note.fromSql(
|
const noteToDelete = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(noteToDelete).not.toBeNull();
|
expect(noteToDelete).not.toBeNull();
|
||||||
|
|
||||||
// Create a Delete request
|
// Create a Delete request
|
||||||
const exampleRequest = new VersiaEntities.Delete({
|
const exampleRequest = new VersiaEntities.Delete({
|
||||||
id: deleteId,
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
author: new URL(`/users/${userId}`, instanceUrl).href,
|
author: userId,
|
||||||
deleted_type: "Note",
|
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(
|
const signedRequest = await sign(
|
||||||
privateKey,
|
instanceKeys.privateKey,
|
||||||
new URL(authorUrl),
|
new URL(exampleRequest.data.author),
|
||||||
new Request(inboxUrl, {
|
new Request(inboxUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type":
|
||||||
Accept: "application/json",
|
"application/vnd.versia+json; charset=utf-8",
|
||||||
|
Accept: "application/vnd.versia+json",
|
||||||
"User-Agent": "Versia/1.0.0",
|
"User-Agent": "Versia/1.0.0",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(exampleRequest.toJSON()),
|
body: JSON.stringify(exampleRequest.toJSON()),
|
||||||
|
|
@ -456,9 +426,7 @@ describe("Inbox Tests", () => {
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
// Verify that the note was deleted from the database
|
// Verify that the note was deleted from the database
|
||||||
const noteExists = await Note.fromSql(
|
const noteExists = await Note.fromSql(eq(Notes.remoteId, noteId));
|
||||||
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(noteExists).toBeNull();
|
expect(noteExists).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||||
import { InboxJobType, inboxQueue } from "@versia-server/kit/queues/inbox";
|
|
||||||
import { describeRoute, validator } from "hono-openapi";
|
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) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.get(
|
||||||
"/inbox",
|
"/.versia/v0.6/inbox",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Instance federation inbox",
|
summary: "Instance inbox endpoint",
|
||||||
tags: ["Federation"],
|
tags: ["Federation"],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
|
|
@ -18,12 +18,9 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"header",
|
"header",
|
||||||
z.object({
|
z.object({
|
||||||
"versia-signature": z.string().optional(),
|
"versia-signature": z.string(),
|
||||||
"versia-signed-at": z.coerce.number().optional(),
|
"versia-signed-at": z.coerce.number(),
|
||||||
"versia-signed-by": z
|
"versia-signed-by": z.string(),
|
||||||
.url()
|
|
||||||
.or(z.string().startsWith("instance "))
|
|
||||||
.optional(),
|
|
||||||
authorization: z.string().optional(),
|
authorization: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
handleZodError,
|
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 { 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 { describeRoute, resolver } from "hono-openapi";
|
||||||
import { urlToContentFormat } from "@/content_types";
|
import z from "zod";
|
||||||
import pkg from "../../../../package.json" with { type: "json" };
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
"/.well-known/versia",
|
"/.well-known/versia",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Get instance metadata",
|
summary: "Get supported versia protocol versions",
|
||||||
tags: ["Federation"],
|
tags: ["Federation"],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Instance metadata",
|
description: "Instance metadata",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(InstanceMetadataSchema),
|
schema: resolver(
|
||||||
|
z.strictObject({
|
||||||
|
versions: z.array(z.string().min(1)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (context) => {
|
(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(
|
return context.json(
|
||||||
{
|
{
|
||||||
type: "InstanceMetadata" as const,
|
versions: ["0.6.0"],
|
||||||
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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sign } from "@versia/sdk/crypto";
|
||||||
import * as VersiaEntities from "@versia/sdk/entities";
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { FederationRequester } from "@versia/sdk/http";
|
import { FederationRequester } from "@versia/sdk/http";
|
||||||
import { config } from "@versia-server/config";
|
import { config } from "@versia-server/config";
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
inArray,
|
inArray,
|
||||||
type SQL,
|
type SQL,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
|
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
|
||||||
import { ApiError } from "../api-error.ts";
|
import { ApiError } from "../api-error.ts";
|
||||||
import { db } from "../tables/db.ts";
|
import { db } from "../tables/db.ts";
|
||||||
import { Instances } from "../tables/schema.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> {
|
public static async fromUser(user: User): Promise<Instance | null> {
|
||||||
if (!user.data.instanceId) {
|
if (!user.data.instanceId) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -139,29 +148,24 @@ export class Instance extends BaseInterface<typeof Instances> {
|
||||||
return this.data.id;
|
return this.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async fetchMetadata(url: URL): Promise<{
|
public static async fetchMetadata(domain: string): Promise<{
|
||||||
metadata: VersiaEntities.InstanceMetadata;
|
metadata: VersiaEntities.InstanceMetadata;
|
||||||
protocol: "versia" | "activitypub";
|
protocol: "versia" | "activitypub";
|
||||||
}> {
|
}> {
|
||||||
const origin = new URL(url).origin;
|
|
||||||
const wellKnownUrl = new URL("/.well-known/versia", origin);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await new FederationRequester(
|
const metadata =
|
||||||
config.instance.keys.private,
|
await Instance.federationRequester.resolveInstance(domain);
|
||||||
config.http.base_url,
|
|
||||||
).fetchEntity(wellKnownUrl, VersiaEntities.InstanceMetadata);
|
|
||||||
|
|
||||||
return { metadata, protocol: "versia" };
|
return { metadata, protocol: "versia" };
|
||||||
} catch {
|
} catch {
|
||||||
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
|
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
|
||||||
// Try to resolve ActivityPub metadata instead
|
// Try to resolve ActivityPub metadata instead
|
||||||
const data = await Instance.fetchActivityPubMetadata(url);
|
const data = await Instance.fetchActivityPubMetadata(domain);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
404,
|
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(
|
private static async fetchActivityPubMetadata(
|
||||||
url: URL,
|
domain: string,
|
||||||
): Promise<VersiaEntities.InstanceMetadata | null> {
|
): Promise<VersiaEntities.InstanceMetadata | null> {
|
||||||
const origin = new URL(url).origin;
|
const origin = new URL(`https://${domain}`);
|
||||||
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
|
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
|
||||||
|
|
||||||
// Go to endpoint, then follow the links to the actual metadata
|
// Go to endpoint, then follow the links to the actual metadata
|
||||||
|
|
@ -254,7 +258,7 @@ export class Instance extends BaseInterface<typeof Instances> {
|
||||||
key: "",
|
key: "",
|
||||||
algorithm: "ed25519",
|
algorithm: "ed25519",
|
||||||
},
|
},
|
||||||
host: new URL(url).host,
|
domain: origin.hostname,
|
||||||
compatibility: {
|
compatibility: {
|
||||||
extensions: [],
|
extensions: [],
|
||||||
versions: [],
|
versions: [],
|
||||||
|
|
@ -268,50 +272,33 @@ export class Instance extends BaseInterface<typeof Instances> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static resolveFromHost(host: string): Promise<Instance> {
|
public static async resolve(domain: 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;
|
|
||||||
|
|
||||||
const existingInstance = await Instance.fromSql(
|
const existingInstance = await Instance.fromSql(
|
||||||
eq(Instances.baseUrl, host),
|
eq(Instances.baseUrl, domain),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingInstance) {
|
if (existingInstance) {
|
||||||
return existingInstance;
|
return existingInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = await Instance.fetchMetadata(url);
|
const output = await Instance.fetchMetadata(domain);
|
||||||
|
|
||||||
const { metadata, protocol } = output;
|
const { metadata, protocol } = output;
|
||||||
|
|
||||||
return Instance.insert({
|
return Instance.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
baseUrl: host,
|
baseUrl: domain,
|
||||||
name: metadata.data.name,
|
name: metadata.data.name,
|
||||||
version: metadata.data.software.version,
|
version: metadata.data.software.version,
|
||||||
logo: metadata.data.logo,
|
logo: metadata.data.logo,
|
||||||
protocol,
|
protocol,
|
||||||
publicKey: metadata.data.public_key,
|
publicKey: metadata.data.public_key,
|
||||||
inbox: metadata.data.shared_inbox ?? null,
|
|
||||||
extensions: metadata.data.extensions ?? null,
|
extensions: metadata.data.extensions ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateFromRemote(): Promise<Instance> {
|
public async updateFromRemote(): Promise<Instance> {
|
||||||
const output = await Instance.fetchMetadata(
|
const output = await Instance.fetchMetadata(this.data.baseUrl);
|
||||||
new URL(`https://${this.data.baseUrl}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!output) {
|
if (!output) {
|
||||||
federationResolversLogger.error`Failed to update instance ${chalk.bold(
|
federationResolversLogger.error`Failed to update instance ${chalk.bold(
|
||||||
|
|
@ -328,13 +315,39 @@ export class Instance extends BaseInterface<typeof Instances> {
|
||||||
logo: metadata.data.logo,
|
logo: metadata.data.logo,
|
||||||
protocol,
|
protocol,
|
||||||
publicKey: metadata.data.public_key,
|
publicKey: metadata.data.public_key,
|
||||||
inbox: metadata.data.shared_inbox ?? null,
|
|
||||||
extensions: metadata.data.extensions ?? null,
|
extensions: metadata.data.extensions ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this;
|
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> {
|
public async sendMessage(content: string): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint
|
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,22 @@ import {
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { db } from "../tables/db.ts";
|
import { db } from "../tables/db.ts";
|
||||||
import {
|
import {
|
||||||
|
type Instances,
|
||||||
Likes,
|
Likes,
|
||||||
type Notes,
|
type Notes,
|
||||||
Notifications,
|
Notifications,
|
||||||
type Users,
|
type Users,
|
||||||
} from "../tables/schema.ts";
|
} from "../tables/schema.ts";
|
||||||
import { BaseInterface } from "./base.ts";
|
import { BaseInterface } from "./base.ts";
|
||||||
import { User } from "./user.ts";
|
import type { User } from "./user.ts";
|
||||||
|
|
||||||
type LikeType = InferSelectModel<typeof Likes> & {
|
type LikeType = InferSelectModel<typeof Likes> & {
|
||||||
liker: InferSelectModel<typeof Users>;
|
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> {
|
export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
|
|
@ -57,7 +62,15 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
where: sql,
|
where: sql,
|
||||||
orderBy,
|
orderBy,
|
||||||
with: {
|
with: {
|
||||||
liked: true,
|
liked: {
|
||||||
|
with: {
|
||||||
|
author: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
liker: true,
|
liker: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -65,6 +78,7 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
if (!found) {
|
if (!found) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Like(found);
|
return new Like(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +87,6 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
orderBy: SQL<unknown> | undefined = desc(Likes.id),
|
orderBy: SQL<unknown> | undefined = desc(Likes.id),
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
extra?: Parameters<typeof db.query.Likes.findMany>[0],
|
|
||||||
): Promise<Like[]> {
|
): Promise<Like[]> {
|
||||||
const found = await db.query.Likes.findMany({
|
const found = await db.query.Likes.findMany({
|
||||||
where: sql,
|
where: sql,
|
||||||
|
|
@ -81,9 +94,16 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
with: {
|
with: {
|
||||||
liked: true,
|
liked: {
|
||||||
|
with: {
|
||||||
|
author: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
liker: true,
|
liker: true,
|
||||||
...extra?.with,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -146,37 +166,28 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public toVersia(): VersiaEntities.Like {
|
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({
|
return new VersiaEntities.Like({
|
||||||
id: this.data.id,
|
id: this.id,
|
||||||
author: User.getUri(
|
author: this.data.liker.id,
|
||||||
this.data.liker.id,
|
|
||||||
this.data.liker.uri ? new URL(this.data.liker.uri) : null,
|
|
||||||
).href,
|
|
||||||
type: "pub.versia:likes/Like",
|
type: "pub.versia:likes/Like",
|
||||||
created_at: this.data.createdAt.toISOString(),
|
created_at: this.data.createdAt.toISOString(),
|
||||||
liked: this.data.liked.uri
|
liked: likedReference,
|
||||||
? new URL(this.data.liked.uri).href
|
|
||||||
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
|
|
||||||
.href,
|
|
||||||
uri: this.getUri().href,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
|
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
|
||||||
return new VersiaEntities.Delete({
|
return new VersiaEntities.Delete({
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
id: crypto.randomUUID(),
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
author: User.getUri(
|
author: unliker ? unliker.id : this.data.liker.id,
|
||||||
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,
|
|
||||||
deleted_type: "pub.versia:likes/Like",
|
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]: {
|
[file.type]: {
|
||||||
content: uri.toString(),
|
content: uri.toString(),
|
||||||
remote: true,
|
remote: true,
|
||||||
hash: {
|
hash,
|
||||||
sha256: hash,
|
|
||||||
},
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
description: options?.description,
|
description: options?.description,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type {
|
||||||
Status as StatusSchema,
|
Status as StatusSchema,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import * as VersiaEntities from "@versia/sdk/entities";
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { FederationRequester } from "@versia/sdk/http";
|
|
||||||
import type { NonTextContentFormatSchema } from "@versia/sdk/schemas";
|
import type { NonTextContentFormatSchema } from "@versia/sdk/schemas";
|
||||||
import { config } from "@versia-server/config";
|
import { config } from "@versia-server/config";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
|
|
@ -25,7 +24,6 @@ import { mergeAndDeduplicate } from "@/lib.ts";
|
||||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||||
import { versiaTextToHtml } from "../parsers.ts";
|
import { versiaTextToHtml } from "../parsers.ts";
|
||||||
import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
|
import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
|
||||||
import { uuid } from "../regex.ts";
|
|
||||||
import { db } from "../tables/db.ts";
|
import { db } from "../tables/db.ts";
|
||||||
import {
|
import {
|
||||||
EmojiToNote,
|
EmojiToNote,
|
||||||
|
|
@ -166,8 +164,24 @@ const findManyNotes = async (
|
||||||
: sql`false`.as("liked"),
|
: sql`false`.as("liked"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reply: true,
|
reply: {
|
||||||
quote: true,
|
with: {
|
||||||
|
author: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quote: {
|
||||||
|
with: {
|
||||||
|
author: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extras: {
|
extras: {
|
||||||
pinned: userId
|
pinned: userId
|
||||||
|
|
@ -197,19 +211,13 @@ const findManyNotes = async (
|
||||||
return output.map((post) => ({
|
return output.map((post) => ({
|
||||||
...post,
|
...post,
|
||||||
author: transformOutputToUserWithRelations(post.author),
|
author: transformOutputToUserWithRelations(post.author),
|
||||||
mentions: post.mentions.map((mention) => ({
|
mentions: post.mentions.map((mention) => mention.user),
|
||||||
...mention.user,
|
|
||||||
endpoints: mention.user.endpoints,
|
|
||||||
})),
|
|
||||||
attachments: post.attachments.map((attachment) => attachment.media),
|
attachments: post.attachments.map((attachment) => attachment.media),
|
||||||
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
||||||
reblog: post.reblog && {
|
reblog: post.reblog && {
|
||||||
...post.reblog,
|
...post.reblog,
|
||||||
author: transformOutputToUserWithRelations(post.reblog.author),
|
author: transformOutputToUserWithRelations(post.reblog.author),
|
||||||
mentions: post.reblog.mentions.map((mention) => ({
|
mentions: post.reblog.mentions.map((mention) => mention.user),
|
||||||
...mention.user,
|
|
||||||
endpoints: mention.user.endpoints,
|
|
||||||
})),
|
|
||||||
attachments: post.reblog.attachments.map(
|
attachments: post.reblog.attachments.map(
|
||||||
(attachment) => attachment.media,
|
(attachment) => attachment.media,
|
||||||
),
|
),
|
||||||
|
|
@ -236,8 +244,20 @@ type NoteTypeWithRelations = NoteType & {
|
||||||
attachments: (typeof Media.$type)[];
|
attachments: (typeof Media.$type)[];
|
||||||
reblog: NoteTypeWithoutRecursiveRelations | null;
|
reblog: NoteTypeWithoutRecursiveRelations | null;
|
||||||
emojis: (typeof Emoji.$type)[];
|
emojis: (typeof Emoji.$type)[];
|
||||||
reply: NoteType | null;
|
reply:
|
||||||
quote: NoteType | null;
|
| (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;
|
client: typeof Client.$type | null;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
reblogged: boolean;
|
reblogged: boolean;
|
||||||
|
|
@ -404,6 +424,17 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
return this.data.id;
|
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> {
|
public async federateToUsers(): Promise<void> {
|
||||||
const users = await this.getUsersToFederateTo();
|
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.
|
* 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 reblogger The user reblogging the note
|
||||||
* @param visibility The visibility of the reblog
|
* @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
|
* @returns The reblog object created or the existing reblog
|
||||||
*/
|
*/
|
||||||
public async reblog(
|
public async reblog(
|
||||||
reblogger: User,
|
reblogger: User,
|
||||||
visibility: z.infer<typeof StatusSchema.shape.visibility>,
|
visibility: z.infer<typeof StatusSchema.shape.visibility>,
|
||||||
uri?: URL,
|
remoteId?: string,
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
const existingReblog = await Note.fromSql(
|
const existingReblog = await Note.fromSql(
|
||||||
and(eq(Notes.authorId, reblogger.id), eq(Notes.reblogId, this.id)),
|
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,
|
sensitive: false,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
clientId: null,
|
clientId: null,
|
||||||
uri: uri?.href,
|
remoteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.recalculateReblogCount();
|
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.
|
* 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 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
|
* @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
|
// Check if the user has already liked the note
|
||||||
const existingLike = await Like.fromSql(
|
const existingLike = await Like.fromSql(
|
||||||
and(eq(Likes.likerId, liker.id), eq(Likes.likedId, this.id)),
|
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(),
|
id: randomUUIDv7(),
|
||||||
likerId: liker.id,
|
likerId: liker.id,
|
||||||
likedId: this.id,
|
likedId: this.id,
|
||||||
uri: uri?.href,
|
remoteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.recalculateLikeCount();
|
await this.recalculateLikeCount();
|
||||||
|
|
@ -904,73 +935,84 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a note from a URI
|
* Resolve a note from a reference
|
||||||
* @param uri - The URI of the note to resolve
|
* @param reference - The URI of the note to resolve
|
||||||
* @returns The resolved note
|
* @returns The resolved note
|
||||||
*/
|
*/
|
||||||
public static async resolve(uri: URL): Promise<Note | null> {
|
public static async resolve(
|
||||||
|
reference: VersiaEntities.Reference,
|
||||||
|
): Promise<Note | null> {
|
||||||
// Check if note not already in database
|
// Check if note not already in database
|
||||||
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
|
if (
|
||||||
|
!reference.domain ||
|
||||||
|
reference.domain === config.http.base_url.hostname
|
||||||
|
) {
|
||||||
|
return await Note.fromId(reference.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await Instance.resolve(reference.domain);
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundNote = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.remoteId, reference.id),
|
||||||
|
eq(
|
||||||
|
Notes.authorId,
|
||||||
|
sql`(
|
||||||
|
SELECT "Users".id FROM "Users"
|
||||||
|
WHERE "Users".instanceId = ${instance.id}
|
||||||
|
LIMIT 1
|
||||||
|
)`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (foundNote) {
|
if (foundNote) {
|
||||||
return foundNote;
|
return foundNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if URI is of a local note
|
return Note.fromVersia(reference);
|
||||||
if (uri.origin === config.http.base_url.origin) {
|
|
||||||
const noteUuid = uri.pathname.match(uuid);
|
|
||||||
|
|
||||||
if (!noteUuid?.[0]) {
|
|
||||||
throw new Error(
|
|
||||||
`URI ${uri} is of a local note, but it could not be parsed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await Note.fromId(noteUuid[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Note.fromVersia(uri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes a Versia Note representation, and serializes it to the database.
|
* Takes a Versia Note representation, and serializes it to the database.
|
||||||
*
|
*
|
||||||
* If the note already exists, it will update it.
|
* 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(
|
public static async fromVersia(
|
||||||
versiaNote: VersiaEntities.Note | URL,
|
versiaNote: VersiaEntities.Note | VersiaEntities.Reference,
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
if (versiaNote instanceof URL) {
|
if (versiaNote instanceof VersiaEntities.Reference) {
|
||||||
// No bridge support for notes yet
|
// No bridge support for notes yet
|
||||||
const note = await new FederationRequester(
|
const note = await Instance.federationRequester.fetchEntity(
|
||||||
config.instance.keys.private,
|
versiaNote,
|
||||||
config.http.base_url,
|
VersiaEntities.Note,
|
||||||
).fetchEntity(versiaNote, VersiaEntities.Note);
|
);
|
||||||
|
|
||||||
return Note.fromVersia(note);
|
return Note.fromVersia(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { created_at, extensions, group, id, is_sensitive, subject } =
|
||||||
author: authorUrl,
|
versiaNote.data;
|
||||||
created_at,
|
|
||||||
uri,
|
if (!versiaNote.author.domain) {
|
||||||
extensions,
|
throw new Error("Entity author domain is missing");
|
||||||
group,
|
}
|
||||||
is_sensitive,
|
|
||||||
mentions: noteMentions,
|
const instance = await Instance.resolve(versiaNote.author.domain);
|
||||||
quotes,
|
const author = await User.resolve(versiaNote.author);
|
||||||
replies_to,
|
|
||||||
subject,
|
|
||||||
} = versiaNote.data;
|
|
||||||
const instance = await Instance.resolve(new URL(authorUrl));
|
|
||||||
const author = await User.resolve(new URL(authorUrl));
|
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new Error("Entity author could not be resolved");
|
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 =
|
const note =
|
||||||
existingNote ??
|
existingNote ??
|
||||||
|
|
@ -978,7 +1020,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
authorId: author.id,
|
authorId: author.id,
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
uri,
|
remoteId: id,
|
||||||
createdAt: new Date(created_at),
|
createdAt: new Date(created_at),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -999,9 +1041,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
|
|
||||||
const mentions = (
|
const mentions = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
noteMentions?.map((mention) =>
|
versiaNote.mentions.map((m) => User.resolve(m)) ?? [],
|
||||||
User.resolve(new URL(mention)),
|
|
||||||
) ?? [],
|
|
||||||
)
|
)
|
||||||
).filter((m) => m !== null);
|
).filter((m) => m !== null);
|
||||||
|
|
||||||
|
|
@ -1011,10 +1051,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
? "direct"
|
? "direct"
|
||||||
: (group as "public" | "followers" | "unlisted");
|
: (group as "public" | "followers" | "unlisted");
|
||||||
|
|
||||||
const reply = replies_to
|
const reply = versiaNote.repliesTo
|
||||||
? await Note.resolve(new URL(replies_to))
|
? await Note.resolve(versiaNote.repliesTo)
|
||||||
|
: null;
|
||||||
|
const quote = versiaNote.quotes
|
||||||
|
? await Note.resolve(versiaNote.quotes)
|
||||||
: null;
|
: null;
|
||||||
const quote = quotes ? await Note.resolve(new URL(quotes)) : null;
|
|
||||||
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
|
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
|
||||||
|
|
||||||
await note.update({
|
await note.update({
|
||||||
|
|
@ -1169,9 +1211,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
mention.username,
|
mention.username,
|
||||||
mention.instance?.baseUrl,
|
mention.instance?.baseUrl,
|
||||||
),
|
),
|
||||||
url: User.getUri(
|
url: new URL(
|
||||||
mention.id,
|
`/@${mention.username}${
|
||||||
mention.uri ? new URL(mention.uri) : null,
|
mention.instance ? `@${mention.instance.baseUrl}` : ""
|
||||||
|
}`,
|
||||||
|
config.http.base_url,
|
||||||
).toString(),
|
).toString(),
|
||||||
username: mention.username,
|
username: mention.username,
|
||||||
})),
|
})),
|
||||||
|
|
@ -1191,9 +1235,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
sensitive: data.sensitive,
|
sensitive: data.sensitive,
|
||||||
spoiler_text: data.spoilerText,
|
spoiler_text: data.spoilerText,
|
||||||
tags: [],
|
tags: [],
|
||||||
uri: data.uri || this.getUri().toString(),
|
uri: this.getUri().toString(),
|
||||||
visibility: data.visibility,
|
visibility: data.visibility,
|
||||||
url: data.uri || this.getMastoUri().toString(),
|
url: this.getMastoUri().toString(),
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
quote: data.quotingId
|
quote: data.quotingId
|
||||||
? ((await Note.fromId(data.quotingId, userFetching?.id).then(
|
? ((await Note.fromId(data.quotingId, userFetching?.id).then(
|
||||||
|
|
@ -1207,9 +1251,14 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUri(): URL {
|
public getUri(): URL {
|
||||||
return this.data.uri
|
const domain = this.author.data.instance?.baseUrl
|
||||||
? new URL(this.data.uri)
|
? new URL(`https://${this.author.data.instance.baseUrl}`)
|
||||||
: new URL(`/notes/${this.id}`, config.http.base_url);
|
: config.http.base_url;
|
||||||
|
|
||||||
|
return new URL(
|
||||||
|
`/.versia/v0.6/entities/Note/${this.id}`,
|
||||||
|
`https://${domain}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1224,14 +1273,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteToVersia(): VersiaEntities.Delete {
|
public deleteToVersia(): VersiaEntities.Delete {
|
||||||
const id = crypto.randomUUID();
|
|
||||||
|
|
||||||
return new VersiaEntities.Delete({
|
return new VersiaEntities.Delete({
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
id,
|
author: this.author.id,
|
||||||
author: this.author.uri.href,
|
|
||||||
deleted_type: "Note",
|
deleted_type: "Note",
|
||||||
deleted: this.getUri().href,
|
deleted: this.id,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1242,12 +1288,24 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
*/
|
*/
|
||||||
public toVersia(): VersiaEntities.Note {
|
public toVersia(): VersiaEntities.Note {
|
||||||
const status = this.data;
|
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({
|
return new VersiaEntities.Note({
|
||||||
type: "Note",
|
type: "Note",
|
||||||
created_at: status.createdAt.toISOString(),
|
created_at: status.createdAt.toISOString(),
|
||||||
id: status.id,
|
id: status.id,
|
||||||
author: this.author.uri.href,
|
author: this.author.id,
|
||||||
uri: this.getUri().href,
|
|
||||||
content: {
|
content: {
|
||||||
"text/html": {
|
"text/html": {
|
||||||
content: status.content,
|
content: status.content,
|
||||||
|
|
@ -1258,20 +1316,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
remote: false,
|
remote: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: {
|
previews: [],
|
||||||
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,
|
|
||||||
},
|
|
||||||
attachments: status.attachments.map(
|
attachments: status.attachments.map(
|
||||||
(attachment) =>
|
(attachment) =>
|
||||||
new Media(attachment).toVersia().data as z.infer<
|
new Media(attachment).toVersia().data as z.infer<
|
||||||
|
|
@ -1279,25 +1324,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
>,
|
>,
|
||||||
),
|
),
|
||||||
is_sensitive: status.sensitive,
|
is_sensitive: status.sensitive,
|
||||||
mentions: status.mentions.map(
|
mentions: status.mentions.map((mention) =>
|
||||||
(mention) =>
|
mention.instance
|
||||||
User.getUri(
|
? `${mention.instance.baseUrl}:${mention.id}`
|
||||||
mention.id,
|
: mention.id,
|
||||||
mention.uri ? new URL(mention.uri) : null,
|
|
||||||
).href,
|
|
||||||
),
|
),
|
||||||
quotes: status.quote
|
quotes: quoteReference,
|
||||||
? status.quote.uri
|
replies_to: replyReference,
|
||||||
? 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,
|
|
||||||
subject: status.spoilerText,
|
subject: status.spoilerText,
|
||||||
// TODO: Refactor as part of groups
|
// TODO: Refactor as part of groups
|
||||||
group: status.visibility === "public" ? "public" : "followers",
|
group: status.visibility === "public" ? "public" : "followers",
|
||||||
|
|
@ -1319,26 +1352,22 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
|
|
||||||
return new VersiaEntities.Share({
|
return new VersiaEntities.Share({
|
||||||
type: "pub.versia:share/Share",
|
type: "pub.versia:share/Share",
|
||||||
id: crypto.randomUUID(),
|
author: this.author.id,
|
||||||
author: this.author.uri.href,
|
id: this.id,
|
||||||
uri: new URL(`/shares/${this.id}`, config.http.base_url).href,
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
shared: new Note(this.data.reblog as NoteTypeWithRelations).getUri()
|
shared: this.data.reblog.author.instance
|
||||||
.href,
|
? `${this.data.reblog.author.instance.baseUrl}:${this.data.reblog.id}`
|
||||||
|
: this.data.reblog.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public toVersiaUnshare(): VersiaEntities.Delete {
|
public toVersiaUnshare(): VersiaEntities.Delete {
|
||||||
return new VersiaEntities.Delete({
|
return new VersiaEntities.Delete({
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
id: crypto.randomUUID(),
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
author: User.getUri(
|
author: this.author.id,
|
||||||
this.data.authorId,
|
|
||||||
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
|
||||||
).href,
|
|
||||||
deleted_type: "pub.versia:share/Share",
|
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 * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { config } from "@versia-server/config";
|
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
|
|
@ -12,17 +11,26 @@ import {
|
||||||
type SQL,
|
type SQL,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { db } from "../tables/db.ts";
|
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 { BaseInterface } from "./base.ts";
|
||||||
import { Emoji } from "./emoji.ts";
|
import { Emoji } from "./emoji.ts";
|
||||||
import { Instance } from "./instance.ts";
|
import { Instance } from "./instance.ts";
|
||||||
import type { Note } from "./note.ts";
|
import type { Note } from "./note.ts";
|
||||||
import { User } from "./user.ts";
|
import type { User } from "./user.ts";
|
||||||
|
|
||||||
type ReactionType = InferSelectModel<typeof Reactions> & {
|
type ReactionType = InferSelectModel<typeof Reactions> & {
|
||||||
emoji: typeof Emoji.$type | null;
|
emoji: typeof Emoji.$type | null;
|
||||||
author: InferSelectModel<typeof Users>;
|
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> {
|
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
|
|
@ -64,7 +72,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
author: true,
|
author: true,
|
||||||
note: true,
|
note: {
|
||||||
|
with: {
|
||||||
|
author: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
});
|
});
|
||||||
|
|
@ -96,7 +112,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
author: true,
|
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");
|
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({
|
return new VersiaEntities.Reaction({
|
||||||
uri: this.getUri(config.http.base_url).href,
|
|
||||||
type: "pub.versia:reactions/Reaction",
|
type: "pub.versia:reactions/Reaction",
|
||||||
author: User.getUri(
|
author: this.data.author.id,
|
||||||
this.data.authorId,
|
|
||||||
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
|
||||||
).href,
|
|
||||||
created_at: this.data.createdAt.toISOString(),
|
created_at: this.data.createdAt.toISOString(),
|
||||||
id: this.id,
|
id: this.id,
|
||||||
object: this.data.note.uri
|
object: noteReference,
|
||||||
? new URL(this.data.note.uri).href
|
|
||||||
: new URL(`/notes/${this.data.noteId}`, config.http.base_url)
|
|
||||||
.href,
|
|
||||||
content: this.hasCustomEmoji()
|
content: this.hasCustomEmoji()
|
||||||
? `:${this.data.emoji?.shortcode}:`
|
? `:${this.data.emoji?.shortcode}:`
|
||||||
: this.data.emojiText || "",
|
: this.data.emojiText || "",
|
||||||
|
|
@ -243,14 +266,10 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
public toVersiaUnreact(): VersiaEntities.Delete {
|
public toVersiaUnreact(): VersiaEntities.Delete {
|
||||||
return new VersiaEntities.Delete({
|
return new VersiaEntities.Delete({
|
||||||
type: "Delete",
|
type: "Delete",
|
||||||
id: crypto.randomUUID(),
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
author: User.getUri(
|
author: this.data.authorId,
|
||||||
this.data.authorId,
|
|
||||||
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
|
||||||
).href,
|
|
||||||
deleted_type: "pub.versia:reactions/Reaction",
|
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({
|
return Reaction.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
uri: reactionToConvert.data.uri,
|
remoteId: reactionToConvert.data.id,
|
||||||
authorId: author.id,
|
authorId: author.id,
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
emojiId: emoji ? emoji.id : null,
|
emojiId: emoji ? emoji.id : null,
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,11 @@ import type {
|
||||||
RolePermission,
|
RolePermission,
|
||||||
Source,
|
Source,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { sign } from "@versia/sdk/crypto";
|
|
||||||
import * as VersiaEntities from "@versia/sdk/entities";
|
import * as VersiaEntities from "@versia/sdk/entities";
|
||||||
import { FederationRequester } from "@versia/sdk/http";
|
import { FederationRequester } from "@versia/sdk/http";
|
||||||
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
|
import type { ImageContentFormatSchema } from "@versia/sdk/schemas";
|
||||||
import { config, ProxiableUrl } from "@versia-server/config";
|
import { config, ProxiableUrl } from "@versia-server/config";
|
||||||
import {
|
import { federationDeliveryLogger } from "@versia-server/logging";
|
||||||
federationDeliveryLogger,
|
|
||||||
federationResolversLogger,
|
|
||||||
} from "@versia-server/logging";
|
|
||||||
import { password as bunPassword, randomUUIDv7 } from "bun";
|
import { password as bunPassword, randomUUIDv7 } from "bun";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,10 +29,9 @@ import { htmlToText } from "html-to-text";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { getBestContentType } from "@/content_types";
|
import { getBestContentType } from "@/content_types";
|
||||||
import { randomString } from "@/math";
|
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 { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
|
||||||
import { PushJobType, pushQueue } from "../queues/push/queue.ts";
|
import { PushJobType, pushQueue } from "../queues/push/queue.ts";
|
||||||
import { uuid } from "../regex.ts";
|
|
||||||
import { db } from "../tables/db.ts";
|
import { db } from "../tables/db.ts";
|
||||||
import {
|
import {
|
||||||
EmojiToUser,
|
EmojiToUser,
|
||||||
|
|
@ -79,7 +74,7 @@ export const userRelations = {
|
||||||
|
|
||||||
// TODO: Remove this function and use what drizzle outputs directly instead of transforming it
|
// TODO: Remove this function and use what drizzle outputs directly instead of transforming it
|
||||||
export const transformOutputToUserWithRelations = (
|
export const transformOutputToUserWithRelations = (
|
||||||
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
|
user: InferSelectModel<typeof Users> & {
|
||||||
followerCount: unknown;
|
followerCount: unknown;
|
||||||
followingCount: unknown;
|
followingCount: unknown;
|
||||||
statusCount: unknown;
|
statusCount: unknown;
|
||||||
|
|
@ -96,7 +91,6 @@ export const transformOutputToUserWithRelations = (
|
||||||
roleId: string;
|
roleId: string;
|
||||||
role?: typeof Role.$type;
|
role?: typeof Role.$type;
|
||||||
}[];
|
}[];
|
||||||
endpoints: unknown;
|
|
||||||
},
|
},
|
||||||
): typeof User.$type => {
|
): typeof User.$type => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -104,17 +98,6 @@ export const transformOutputToUserWithRelations = (
|
||||||
followerCount: Number(user.followerCount),
|
followerCount: Number(user.followerCount),
|
||||||
followingCount: Number(user.followingCount),
|
followingCount: Number(user.followingCount),
|
||||||
statusCount: Number(user.statusCount),
|
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(
|
emojis: user.emojis.map(
|
||||||
(emoji) =>
|
(emoji) =>
|
||||||
(emoji as unknown as Record<string, object>)
|
(emoji as unknown as Record<string, object>)
|
||||||
|
|
@ -239,14 +222,26 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
return !this.local;
|
return !this.local;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get uri(): URL {
|
public get reference(): VersiaEntities.Reference {
|
||||||
return this.data.uri
|
if (this.local) {
|
||||||
? new URL(this.data.uri)
|
return new VersiaEntities.Reference(this.id);
|
||||||
: new URL(`/users/${this.data.id}`, config.http.base_url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getUri(id: string, uri: URL | null): URL {
|
return new VersiaEntities.Reference(
|
||||||
return uri ? uri : new URL(`/users/${id}`, config.http.base_url);
|
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 {
|
public hasPermission(permission: RolePermission): boolean {
|
||||||
|
|
@ -335,13 +330,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
|
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
|
||||||
const id = crypto.randomUUID();
|
|
||||||
return new VersiaEntities.Unfollow({
|
return new VersiaEntities.Unfollow({
|
||||||
type: "Unfollow",
|
type: "Unfollow",
|
||||||
id,
|
author: this.id,
|
||||||
author: this.uri.href,
|
|
||||||
created_at: new Date().toISOString(),
|
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({
|
const entity = new VersiaEntities.FollowAccept({
|
||||||
type: "FollowAccept",
|
type: "FollowAccept",
|
||||||
id: crypto.randomUUID(),
|
author: this.id,
|
||||||
author: this.uri.href,
|
|
||||||
created_at: new Date().toISOString(),
|
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, {
|
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
|
||||||
|
|
@ -383,10 +379,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
|
|
||||||
const entity = new VersiaEntities.FollowReject({
|
const entity = new VersiaEntities.FollowReject({
|
||||||
type: "FollowReject",
|
type: "FollowReject",
|
||||||
id: crypto.randomUUID(),
|
author: this.id,
|
||||||
author: this.uri.href,
|
|
||||||
created_at: new Date().toISOString(),
|
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, {
|
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
|
* Perform a WebFinger lookup to find a user's URI
|
||||||
* @param username
|
* @param username
|
||||||
|
|
@ -708,54 +670,41 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
* Takes a Versia User representation, and serializes it to the database.
|
* Takes a Versia User representation, and serializes it to the database.
|
||||||
*
|
*
|
||||||
* If the user already exists, it will update it.
|
* 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(
|
public static async fromVersia(
|
||||||
versiaUser: VersiaEntities.User | URL,
|
versiaUser: VersiaEntities.User | VersiaEntities.Reference,
|
||||||
|
domain: string,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
if (versiaUser instanceof URL) {
|
if (versiaUser instanceof VersiaEntities.Reference) {
|
||||||
let uri = versiaUser;
|
if (!versiaUser.domain) {
|
||||||
const instance = await Instance.resolve(uri);
|
throw new Error(
|
||||||
|
"Cannot fetch Versia user from reference without domain",
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await new FederationRequester(
|
const user = await Instance.federationRequester.fetchEntity(
|
||||||
config.instance.keys.private,
|
versiaUser,
|
||||||
config.http.base_url,
|
VersiaEntities.User,
|
||||||
).fetchEntity(uri, VersiaEntities.User);
|
);
|
||||||
|
|
||||||
return User.fromVersia(user);
|
return User.fromVersia(user, versiaUser.domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
username,
|
username,
|
||||||
inbox,
|
|
||||||
avatar,
|
|
||||||
header,
|
|
||||||
display_name,
|
display_name,
|
||||||
|
id,
|
||||||
fields,
|
fields,
|
||||||
collections,
|
|
||||||
created_at,
|
created_at,
|
||||||
manually_approves_followers,
|
manually_approves_followers,
|
||||||
bio,
|
bio,
|
||||||
public_key,
|
|
||||||
uri,
|
|
||||||
extensions,
|
extensions,
|
||||||
} = versiaUser.data;
|
} = versiaUser.data;
|
||||||
const instance = await Instance.resolve(new URL(versiaUser.data.uri));
|
|
||||||
|
const instance = await Instance.resolve(domain);
|
||||||
const existingUser = await User.fromSql(
|
const existingUser = await User.fromSql(
|
||||||
eq(Users.uri, versiaUser.data.uri),
|
and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const user =
|
const user =
|
||||||
|
|
@ -763,60 +712,50 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
(await User.insert({
|
(await User.insert({
|
||||||
username,
|
username,
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
publicKey: public_key.key,
|
|
||||||
uri,
|
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
|
remoteId: id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Avatars and headers are stored in a separate table, so we need to update them separately
|
// Avatars and headers are stored in a separate table, so we need to update them separately
|
||||||
let userAvatar: Media | null = null;
|
let userAvatar: Media | null = null;
|
||||||
let userHeader: Media | null = null;
|
let userHeader: Media | null = null;
|
||||||
|
|
||||||
if (avatar) {
|
if (versiaUser.avatar) {
|
||||||
if (user.avatar) {
|
if (user.avatar) {
|
||||||
userAvatar = new Media(
|
userAvatar = new Media(
|
||||||
await user.avatar.update({
|
await user.avatar.update({
|
||||||
content: avatar,
|
content: versiaUser.avatar.data,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
userAvatar = await Media.insert({
|
userAvatar = await Media.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
content: avatar,
|
content: versiaUser.avatar.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (header) {
|
if (versiaUser.header) {
|
||||||
if (user.header) {
|
if (user.header) {
|
||||||
userHeader = new Media(
|
userHeader = new Media(
|
||||||
await user.header.update({
|
await user.header.update({
|
||||||
content: header,
|
content: versiaUser.header.data,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
userHeader = await Media.insert({
|
userHeader = await Media.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
content: header,
|
content: versiaUser.header.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await user.update({
|
await user.update({
|
||||||
createdAt: new Date(created_at),
|
createdAt: new Date(created_at),
|
||||||
endpoints: {
|
isLocked: manually_approves_followers,
|
||||||
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,
|
|
||||||
avatarId: userAvatar?.id,
|
avatarId: userAvatar?.id,
|
||||||
headerId: userHeader?.id,
|
headerId: userHeader?.id,
|
||||||
fields: fields ?? [],
|
fields,
|
||||||
displayName: display_name,
|
displayName: display_name,
|
||||||
note: getBestContentType(bio).content,
|
note: getBestContentType(bio).content,
|
||||||
});
|
});
|
||||||
|
|
@ -847,31 +786,39 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async resolve(uri: URL): Promise<User | null> {
|
public static async resolve(
|
||||||
federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`;
|
reference: VersiaEntities.Reference,
|
||||||
|
): Promise<User> {
|
||||||
// Check if user not already in database
|
// Check if user not already in database
|
||||||
const foundUser = await User.fromSql(eq(Users.uri, uri.href));
|
if (
|
||||||
|
!reference.domain ||
|
||||||
|
reference.domain === config.http.base_url.hostname
|
||||||
|
) {
|
||||||
|
const user = await User.fromId(reference.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(
|
||||||
|
"Failed to resolve user reference: User not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await Instance.resolve(reference.domain);
|
||||||
|
|
||||||
|
const foundUser = await User.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Users.instanceId, instance.id),
|
||||||
|
eq(Users.remoteId, reference.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (foundUser) {
|
if (foundUser) {
|
||||||
return foundUser;
|
return foundUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if URI is of a local user
|
return User.fromVersia(reference, reference.domain);
|
||||||
if (uri.origin === config.http.base_url.origin) {
|
|
||||||
const userUuid = uri.href.match(uuid);
|
|
||||||
|
|
||||||
if (!userUuid?.[0]) {
|
|
||||||
throw new Error(
|
|
||||||
`URI ${uri} is of a local user, but it could not be parsed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await User.fromId(userUuid[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
federationResolversLogger.debug`User not found in database, fetching from remote`;
|
|
||||||
|
|
||||||
return User.fromVersia(uri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -890,31 +837,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
return this.avatar?.getUrl();
|
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(
|
public static async register(
|
||||||
username: string,
|
username: string,
|
||||||
options?: Partial<{
|
options?: Partial<{
|
||||||
|
|
@ -924,8 +846,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}>,
|
}>,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const keys = await User.generateKeys();
|
|
||||||
|
|
||||||
const user = await User.insert({
|
const user = await User.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
username,
|
username,
|
||||||
|
|
@ -937,9 +857,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
note: "",
|
note: "",
|
||||||
avatarId: options?.avatar?.id,
|
avatarId: options?.avatar?.id,
|
||||||
isAdmin: options?.isAdmin,
|
isAdmin: options?.isAdmin,
|
||||||
publicKey: keys.public_key,
|
|
||||||
fields: [],
|
fields: [],
|
||||||
privateKey: keys.private_key,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
source: {
|
source: {
|
||||||
language: "en",
|
language: "en",
|
||||||
|
|
@ -999,11 +917,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
newUser.avatar ||
|
newUser.avatar ||
|
||||||
newUser.header ||
|
newUser.header ||
|
||||||
newUser.fields ||
|
newUser.fields ||
|
||||||
newUser.publicKey ||
|
|
||||||
newUser.isAdmin ||
|
newUser.isAdmin ||
|
||||||
newUser.isBot ||
|
newUser.isBot ||
|
||||||
newUser.isLocked ||
|
newUser.isLocked ||
|
||||||
newUser.endpoints ||
|
|
||||||
newUser.isDiscoverable ||
|
newUser.isDiscoverable ||
|
||||||
newUser.isIndexable)
|
newUser.isIndexable)
|
||||||
) {
|
) {
|
||||||
|
|
@ -1013,20 +929,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
return updated.data;
|
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
|
* Get all remote followers of the user
|
||||||
* @returns The remote followers
|
* @returns The remote followers
|
||||||
|
|
@ -1076,17 +978,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
entity: KnownEntity,
|
entity: KnownEntity,
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{ ok: boolean }> {
|
): Promise<{ ok: boolean }> {
|
||||||
const inbox = user.data.instance?.inbox || user.data.endpoints?.inbox;
|
if (!user.data.instance) {
|
||||||
|
throw new Error("Cannot federate to a local user");
|
||||||
if (!inbox) {
|
|
||||||
throw new Error(
|
|
||||||
`User ${chalk.gray(user.uri)} does not have an inbox endpoint`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await (await this.federationRequester).postEntity(
|
await Instance.federationRequester.postEntity(
|
||||||
new URL(inbox),
|
user.data.instance.baseUrl,
|
||||||
entity,
|
entity,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1110,9 +1008,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
display_name: user.displayName || user.username,
|
display_name: user.displayName || user.username,
|
||||||
note: user.note,
|
note: user.note,
|
||||||
uri: this.uri.href,
|
uri: this.uri.href,
|
||||||
url:
|
url: new URL(
|
||||||
user.uri ||
|
`/@${user.username}${
|
||||||
new URL(`/@${user.username}`, config.http.base_url).href,
|
user.instanceId ? `@${user.instance?.baseUrl}` : ""
|
||||||
|
}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href,
|
||||||
avatar: this.getAvatarUrl().proxied,
|
avatar: this.getAvatarUrl().proxied,
|
||||||
header: this.getHeaderUrl()?.proxied ?? "",
|
header: this.getHeaderUrl()?.proxied ?? "",
|
||||||
locked: user.isLocked,
|
locked: user.isLocked,
|
||||||
|
|
@ -1166,7 +1067,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
return new VersiaEntities.User({
|
return new VersiaEntities.User({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
type: "User",
|
type: "User",
|
||||||
uri: this.uri.href,
|
|
||||||
bio: {
|
bio: {
|
||||||
"text/html": {
|
"text/html": {
|
||||||
content: user.note,
|
content: user.note,
|
||||||
|
|
@ -1178,34 +1078,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
created_at: user.createdAt.toISOString(),
|
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,
|
indexable: this.data.isIndexable,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
manually_approves_followers: this.data.isLocked,
|
manually_approves_followers: this.data.isLocked,
|
||||||
|
|
@ -1217,11 +1089,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
>,
|
>,
|
||||||
display_name: user.displayName,
|
display_name: user.displayName,
|
||||||
fields: user.fields,
|
fields: user.fields,
|
||||||
public_key: {
|
|
||||||
actor: new URL(`/users/${user.id}`, config.http.base_url).href,
|
|
||||||
key: user.publicKey,
|
|
||||||
algorithm: "ed25519",
|
|
||||||
},
|
|
||||||
extensions: {
|
extensions: {
|
||||||
"pub.versia:custom_emojis": {
|
"pub.versia:custom_emojis": {
|
||||||
emojis: user.emojis.map((emoji) =>
|
emojis: user.emojis.map((emoji) =>
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,12 @@ export class InboxProcessor {
|
||||||
.on(VersiaEntities.Delete, (d) =>
|
.on(VersiaEntities.Delete, (d) =>
|
||||||
InboxProcessor.processDelete(d),
|
InboxProcessor.processDelete(d),
|
||||||
)
|
)
|
||||||
.on(VersiaEntities.User, (u) => InboxProcessor.processUser(u))
|
.on(VersiaEntities.User, (u) =>
|
||||||
|
InboxProcessor.processUser(
|
||||||
|
u,
|
||||||
|
this.sender?.instance.data.baseUrl ?? "",
|
||||||
|
),
|
||||||
|
)
|
||||||
.on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s))
|
.on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s))
|
||||||
.on(VersiaEntities.Reaction, (r) =>
|
.on(VersiaEntities.Reaction, (r) =>
|
||||||
InboxProcessor.processReaction(r),
|
InboxProcessor.processReaction(r),
|
||||||
|
|
@ -221,8 +226,8 @@ export class InboxProcessor {
|
||||||
private static async processReaction(
|
private static async processReaction(
|
||||||
reaction: VersiaEntities.Reaction,
|
reaction: VersiaEntities.Reaction,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const author = await User.resolve(new URL(reaction.data.author));
|
const author = await User.resolve(reaction.author);
|
||||||
const note = await Note.resolve(new URL(reaction.data.object));
|
const note = await Note.resolve(reaction.object);
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new ApiError(404, "Author not found");
|
throw new ApiError(404, "Author not found");
|
||||||
|
|
@ -264,9 +269,13 @@ export class InboxProcessor {
|
||||||
* Handles User entity processing.
|
* Handles User entity processing.
|
||||||
*
|
*
|
||||||
* @param {VersiaUser} user - The User entity to process.
|
* @param {VersiaUser} user - The User entity to process.
|
||||||
|
* @param {string} domain - The domain of the user.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
private static async processUser(user: VersiaEntities.User): Promise<void> {
|
private static async processUser(
|
||||||
|
user: VersiaEntities.User,
|
||||||
|
domain: string,
|
||||||
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
config.validation.filters.username.some((filter) =>
|
config.validation.filters.username.some((filter) =>
|
||||||
filter.test(user.data.username),
|
filter.test(user.data.username),
|
||||||
|
|
@ -294,7 +303,7 @@ export class InboxProcessor {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await User.fromVersia(user);
|
await User.fromVersia(user, domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -306,8 +315,8 @@ export class InboxProcessor {
|
||||||
private static async processFollowRequest(
|
private static async processFollowRequest(
|
||||||
follow: VersiaEntities.Follow,
|
follow: VersiaEntities.Follow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const author = await User.resolve(new URL(follow.data.author));
|
const author = await User.resolve(follow.author);
|
||||||
const followee = await User.resolve(new URL(follow.data.followee));
|
const followee = await User.resolve(follow.followee);
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new ApiError(404, "Author not found");
|
throw new ApiError(404, "Author not found");
|
||||||
|
|
@ -354,10 +363,8 @@ export class InboxProcessor {
|
||||||
private static async processFollowAccept(
|
private static async processFollowAccept(
|
||||||
followAccept: VersiaEntities.FollowAccept,
|
followAccept: VersiaEntities.FollowAccept,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const author = await User.resolve(new URL(followAccept.data.author));
|
const author = await User.resolve(followAccept.author);
|
||||||
const follower = await User.resolve(
|
const follower = await User.resolve(followAccept.follower);
|
||||||
new URL(followAccept.data.follower),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new ApiError(404, "Author not found");
|
throw new ApiError(404, "Author not found");
|
||||||
|
|
@ -391,10 +398,8 @@ export class InboxProcessor {
|
||||||
private static async processFollowReject(
|
private static async processFollowReject(
|
||||||
followReject: VersiaEntities.FollowReject,
|
followReject: VersiaEntities.FollowReject,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const author = await User.resolve(new URL(followReject.data.author));
|
const author = await User.resolve(followReject.author);
|
||||||
const follower = await User.resolve(
|
const follower = await User.resolve(followReject.follower);
|
||||||
new URL(followReject.data.follower),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new ApiError(404, "Author not found");
|
throw new ApiError(404, "Author not found");
|
||||||
|
|
@ -428,8 +433,8 @@ export class InboxProcessor {
|
||||||
private static async processShare(
|
private static async processShare(
|
||||||
share: VersiaEntities.Share,
|
share: VersiaEntities.Share,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const author = await User.resolve(new URL(share.data.author));
|
const author = await User.resolve(share.author);
|
||||||
const sharedNote = await Note.resolve(new URL(share.data.shared));
|
const sharedNote = await Note.resolve(share.shared);
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new ApiError(404, "Author not found");
|
throw new ApiError(404, "Author not found");
|
||||||
|
|
@ -439,7 +444,7 @@ export class InboxProcessor {
|
||||||
throw new ApiError(404, "Shared Note not found");
|
throw new ApiError(404, "Shared Note not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await sharedNote.reblog(author, "public", new URL(share.data.uri));
|
await sharedNote.reblog(author, "public", share.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -451,17 +456,15 @@ export class InboxProcessor {
|
||||||
public static async processDelete(
|
public static async processDelete(
|
||||||
delete_: VersiaEntities.Delete,
|
delete_: VersiaEntities.Delete,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const toDelete = delete_.data.deleted;
|
const toDelete = delete_.deleted;
|
||||||
|
|
||||||
const author = delete_.data.author
|
const author = await User.resolve(delete_.author);
|
||||||
? await User.resolve(new URL(delete_.data.author))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
switch (delete_.data.deleted_type) {
|
switch (delete_.data.deleted_type) {
|
||||||
case "Note": {
|
case "Note": {
|
||||||
const note = await Note.fromSql(
|
const note = await Note.fromSql(
|
||||||
eq(Notes.uri, toDelete),
|
eq(Notes.remoteId, toDelete.id),
|
||||||
author ? eq(Notes.authorId, author.id) : undefined,
|
eq(Notes.authorId, author.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
|
|
@ -475,7 +478,7 @@ export class InboxProcessor {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "User": {
|
case "User": {
|
||||||
const userToDelete = await User.resolve(new URL(toDelete));
|
const userToDelete = await User.resolve(toDelete);
|
||||||
|
|
||||||
if (!userToDelete) {
|
if (!userToDelete) {
|
||||||
throw new ApiError(404, "User to delete not found");
|
throw new ApiError(404, "User to delete not found");
|
||||||
|
|
@ -490,8 +493,8 @@ export class InboxProcessor {
|
||||||
}
|
}
|
||||||
case "pub.versia:likes/Like": {
|
case "pub.versia:likes/Like": {
|
||||||
const like = await Like.fromSql(
|
const like = await Like.fromSql(
|
||||||
eq(Likes.uri, toDelete),
|
eq(Likes.remoteId, toDelete.id),
|
||||||
author ? eq(Likes.likerId, author.id) : undefined,
|
eq(Likes.likerId, author.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!like) {
|
if (!like) {
|
||||||
|
|
@ -525,7 +528,10 @@ export class InboxProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const reblog = await Note.fromSql(
|
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) {
|
if (!reblog) {
|
||||||
|
|
@ -568,8 +574,8 @@ export class InboxProcessor {
|
||||||
private static async processLikeRequest(
|
private static async processLikeRequest(
|
||||||
like: VersiaEntities.Like,
|
like: VersiaEntities.Like,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const author = await User.resolve(new URL(like.data.author));
|
const author = await User.resolve(like.author);
|
||||||
const likedNote = await Note.resolve(new URL(like.data.liked));
|
const likedNote = await Note.resolve(like.liked);
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
throw new ApiError(404, "Author not found");
|
throw new ApiError(404, "Author not found");
|
||||||
|
|
@ -579,7 +585,7 @@ export class InboxProcessor {
|
||||||
throw new ApiError(404, "Liked Note not found");
|
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 { FederationRequester } from "@versia/sdk/http";
|
||||||
import { config } from "@versia-server/config";
|
import { config } from "@versia-server/config";
|
||||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
letter,
|
letter,
|
||||||
} from "magic-regexp";
|
} from "magic-regexp";
|
||||||
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
|
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
|
||||||
|
import { Instance } from "./db/instance.ts";
|
||||||
import { User } from "./db/user.ts";
|
import { User } from "./db/user.ts";
|
||||||
import { markdownToHtml } from "./markdown.ts";
|
import { markdownToHtml } from "./markdown.ts";
|
||||||
import { mention } from "./regex.ts";
|
import { mention } from "./regex.ts";
|
||||||
|
|
@ -81,7 +82,12 @@ export const parseMentionsFromText = async (text: string): Promise<User[]> => {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
const user = await User.resolve(url);
|
const userEntity = await Instance.federationRequester.fetchSigned(
|
||||||
|
url,
|
||||||
|
VersiaEntities.User,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await User.fromVersia(userEntity, url.hostname);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
finalList.push(user);
|
finalList.push(user);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
|
||||||
await job.log(`Fetching instance metadata from [${uri}]`);
|
await job.log(`Fetching instance metadata from [${uri}]`);
|
||||||
|
|
||||||
// Check if exists
|
// Check if exists
|
||||||
const host = new URL(uri).host;
|
const host = new URL(uri).hostname;
|
||||||
|
|
||||||
const existingInstance = await Instance.fromSql(
|
const existingInstance = await Instance.fromSql(
|
||||||
eq(Instances.baseUrl, host),
|
eq(Instances.baseUrl, host),
|
||||||
|
|
@ -37,7 +37,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Instance.resolve(new URL(uri));
|
await Instance.resolve(host);
|
||||||
|
|
||||||
await job.log(
|
await job.log(
|
||||||
`✔ Finished fetching instance metadata from [${uri}]`,
|
`✔ Finished fetching instance metadata from [${uri}]`,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { config } from "@versia-server/config";
|
||||||
import { Worker } from "bullmq";
|
import { Worker } from "bullmq";
|
||||||
import { ApiError } from "../../api-error.ts";
|
import { ApiError } from "../../api-error.ts";
|
||||||
import { Instance } from "../../db/instance.ts";
|
import { Instance } from "../../db/instance.ts";
|
||||||
import { User } from "../../db/user.ts";
|
|
||||||
import { InboxProcessor } from "../../inbox-processor.ts";
|
import { InboxProcessor } from "../../inbox-processor.ts";
|
||||||
import { connection } from "../../redis.ts";
|
import { connection } from "../../redis.ts";
|
||||||
import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts";
|
import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts";
|
||||||
|
|
@ -72,51 +71,29 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
||||||
"versia-signed-by": string;
|
"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(
|
await job.log(
|
||||||
`Could not resolve sender URI [${signedBy}]`,
|
`Could not resolve sender domain [${signedBy}]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
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(
|
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(
|
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(
|
const key = await crypto.subtle.importKey(
|
||||||
"spki",
|
"spki",
|
||||||
Buffer.from(
|
Buffer.from(sender.data.publicKey.key, "base64"),
|
||||||
sender?.data.publicKey ??
|
|
||||||
remoteInstance.data.publicKey.key,
|
|
||||||
"base64",
|
|
||||||
),
|
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
false,
|
false,
|
||||||
["verify"],
|
["verify"],
|
||||||
|
|
@ -127,7 +104,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
||||||
req,
|
req,
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
instance: remoteInstance,
|
instance: sender,
|
||||||
key,
|
key,
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -147,10 +124,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
||||||
);
|
);
|
||||||
|
|
||||||
await job.log(
|
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 [${
|
`Failed processing entity [${
|
||||||
data.uri
|
data.uri
|
||||||
}] delivered to inbox. Returned error:\n\n${JSON.stringify(
|
}] delivered to inbox. Returned error:\n\n${JSON.stringify(
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ export const PushSubscriptionsRelations = relations(
|
||||||
export const Reactions = pgTable("Reaction", {
|
export const Reactions = pgTable("Reaction", {
|
||||||
id: id(),
|
id: id(),
|
||||||
uri: uri(),
|
uri: uri(),
|
||||||
|
remoteId: text("remote_id"),
|
||||||
// Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used
|
// Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used
|
||||||
emojiId: uuid("emojiId").references(() => Emojis.id, {
|
emojiId: uuid("emojiId").references(() => Emojis.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
|
|
@ -244,7 +245,7 @@ export const Markers = pgTable("Markers", {
|
||||||
|
|
||||||
export const Likes = pgTable("Likes", {
|
export const Likes = pgTable("Likes", {
|
||||||
id: id(),
|
id: id(),
|
||||||
uri: uri(),
|
remoteId: text("remote_id"),
|
||||||
likerId: uuid("likerId")
|
likerId: uuid("likerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => Users.id, {
|
.references(() => Users.id, {
|
||||||
|
|
@ -472,7 +473,7 @@ export const NotificationsRelations = relations(Notifications, ({ one }) => ({
|
||||||
|
|
||||||
export const Notes = pgTable("Notes", {
|
export const Notes = pgTable("Notes", {
|
||||||
id: id(),
|
id: id(),
|
||||||
uri: uri(),
|
remoteId: text("remote_id"),
|
||||||
authorId: uuid("authorId")
|
authorId: uuid("authorId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => Users.id, {
|
.references(() => Users.id, {
|
||||||
|
|
@ -600,7 +601,7 @@ export const Users = pgTable(
|
||||||
"Users",
|
"Users",
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
uri: uri(),
|
remoteId: text("remote_id"),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
displayName: text("display_name"),
|
displayName: text("display_name"),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
|
|
@ -615,15 +616,6 @@ export const Users = pgTable(
|
||||||
value: z.infer<typeof TextContentFormatSchema>;
|
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>>(),
|
source: jsonb("source").$type<z.infer<typeof Source>>(),
|
||||||
avatarId: uuid("avatarId").references(() => Medias.id, {
|
avatarId: uuid("avatarId").references(() => Medias.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
|
|
@ -646,8 +638,6 @@ export const Users = pgTable(
|
||||||
.notNull(),
|
.notNull(),
|
||||||
isIndexable: boolean("is_indexable").default(true).notNull(),
|
isIndexable: boolean("is_indexable").default(true).notNull(),
|
||||||
sanctions: text("sanctions").array(),
|
sanctions: text("sanctions").array(),
|
||||||
publicKey: text("public_key").notNull(),
|
|
||||||
privateKey: text("private_key"),
|
|
||||||
instanceId: uuid("instanceId").references(() => Instances.id, {
|
instanceId: uuid("instanceId").references(() => Instances.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
onUpdate: "cascade",
|
onUpdate: "cascade",
|
||||||
|
|
@ -656,11 +646,7 @@ export const Users = pgTable(
|
||||||
.default(false)
|
.default(false)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index().on(table.username), uniqueIndex().on(table.email)],
|
||||||
uniqueIndex().on(table.uri),
|
|
||||||
index().on(table.username),
|
|
||||||
uniqueIndex().on(table.email),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const UsersRelations = relations(Users, ({ many, one }) => ({
|
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.
|
* Signs a request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification.
|
||||||
*
|
*
|
||||||
* @see https://versia.pub/signatures
|
* @see https://versia.pub/signatures
|
||||||
* @param privateKey - Private key of the User that is signing the request.
|
* @param privateKey - Private key of the instance that is signing the request.
|
||||||
* @param authorUrl - URL of the User that is signing the request.
|
* @param instance - URL of the instance that is signing the request.
|
||||||
* @param req - Request to sign.
|
* @param req - Request to sign.
|
||||||
* @param timestamp - (optional) Timestamp of the request.
|
* @param timestamp - (optional) Timestamp of the request.
|
||||||
* @returns The signed request.
|
* @returns The signed request.
|
||||||
*/
|
*/
|
||||||
export const sign = async (
|
export const sign = async (
|
||||||
privateKey: CryptoKey,
|
privateKey: CryptoKey,
|
||||||
authorUrl: URL,
|
instance: URL,
|
||||||
req: Request,
|
req: Request,
|
||||||
timestamp = new Date(),
|
timestamp = new Date(),
|
||||||
): Promise<Request> => {
|
): Promise<Request> => {
|
||||||
|
|
@ -48,7 +48,7 @@ export const sign = async (
|
||||||
...req.headers,
|
...req.headers,
|
||||||
"Versia-Signature": signatureBase64,
|
"Versia-Signature": signatureBase64,
|
||||||
"Versia-Signed-At": String(timestampSecs),
|
"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 type { z } from "zod";
|
||||||
import { DeleteSchema } from "../schemas/delete.ts";
|
import { DeleteSchema } from "../schemas/delete.ts";
|
||||||
import type { JSONObject } from "../types.ts";
|
import type { JSONObject } from "../types.ts";
|
||||||
import { Entity } from "./entity.ts";
|
import { Entity, Reference } from "./entity.ts";
|
||||||
|
|
||||||
export class Delete extends Entity {
|
export class Delete extends Entity {
|
||||||
public static override name = "Delete";
|
public static override name = "Delete";
|
||||||
|
|
@ -10,6 +10,14 @@ export class Delete extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Delete> {
|
||||||
return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
|
return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,31 @@ export class Entity {
|
||||||
return this.data;
|
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 type { z } from "zod";
|
||||||
import { DislikeSchema, LikeSchema } from "../../schemas/extensions/likes.ts";
|
import { DislikeSchema, LikeSchema } from "../../schemas/extensions/likes.ts";
|
||||||
import type { JSONObject } from "../../types.ts";
|
import type { JSONObject } from "../../types.ts";
|
||||||
import { Entity } from "../entity.ts";
|
import { Entity, Reference } from "../entity.ts";
|
||||||
|
|
||||||
export class Like extends Entity {
|
export class Like extends Entity {
|
||||||
public static override name = "pub.versia:likes/Like";
|
public static override name = "pub.versia:likes/Like";
|
||||||
|
|
@ -10,6 +10,14 @@ export class Like extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Like> {
|
||||||
return LikeSchema.parseAsync(json).then((u) => new Like(u));
|
return LikeSchema.parseAsync(json).then((u) => new Like(u));
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +30,14 @@ export class Dislike extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Dislike> {
|
||||||
return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
|
return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { VoteSchema } from "../../schemas/extensions/polls.ts";
|
import { VoteSchema } from "../../schemas/extensions/polls.ts";
|
||||||
import type { JSONObject } from "../../types.ts";
|
import type { JSONObject } from "../../types.ts";
|
||||||
import { Entity } from "../entity.ts";
|
import { Entity, Reference } from "../entity.ts";
|
||||||
|
|
||||||
export class Vote extends Entity {
|
export class Vote extends Entity {
|
||||||
public static override name = "pub.versia:polls/Vote";
|
public static override name = "pub.versia:polls/Vote";
|
||||||
|
|
@ -10,6 +10,14 @@ export class Vote extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Vote> {
|
||||||
return VoteSchema.parseAsync(json).then((u) => new Vote(u));
|
return VoteSchema.parseAsync(json).then((u) => new Vote(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { ReactionSchema } from "../../schemas/extensions/reactions.ts";
|
import { ReactionSchema } from "../../schemas/extensions/reactions.ts";
|
||||||
import type { JSONObject } from "../../types.ts";
|
import type { JSONObject } from "../../types.ts";
|
||||||
import { Entity } from "../entity.ts";
|
import { Entity, Reference } from "../entity.ts";
|
||||||
|
|
||||||
export class Reaction extends Entity {
|
export class Reaction extends Entity {
|
||||||
public static override name = "pub.versia:reactions/Reaction";
|
public static override name = "pub.versia:reactions/Reaction";
|
||||||
|
|
@ -10,6 +10,14 @@ export class Reaction extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Reaction> {
|
||||||
return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
|
return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { ReportSchema } from "../../schemas/extensions/reports.ts";
|
import { ReportSchema } from "../../schemas/extensions/reports.ts";
|
||||||
import type { JSONObject } from "../../types.ts";
|
import type { JSONObject } from "../../types.ts";
|
||||||
import { Entity } from "../entity.ts";
|
import { Entity, Reference } from "../entity.ts";
|
||||||
|
|
||||||
export class Report extends Entity {
|
export class Report extends Entity {
|
||||||
public static override name = "pub.versia:reports/Report";
|
public static override name = "pub.versia:reports/Report";
|
||||||
|
|
@ -10,6 +10,14 @@ export class Report extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Report> {
|
||||||
return ReportSchema.parseAsync(json).then((u) => new Report(u));
|
return ReportSchema.parseAsync(json).then((u) => new Report(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { ShareSchema } from "../../schemas/extensions/share.ts";
|
import { ShareSchema } from "../../schemas/extensions/share.ts";
|
||||||
import type { JSONObject } from "../../types.ts";
|
import type { JSONObject } from "../../types.ts";
|
||||||
import { Entity } from "../entity.ts";
|
import { Entity, Reference } from "../entity.ts";
|
||||||
|
|
||||||
export class Share extends Entity {
|
export class Share extends Entity {
|
||||||
public static override name = "pub.versia:share/Share";
|
public static override name = "pub.versia:share/Share";
|
||||||
|
|
@ -10,6 +10,14 @@ export class Share extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Share> {
|
||||||
return ShareSchema.parseAsync(json).then((u) => new Share(u));
|
return ShareSchema.parseAsync(json).then((u) => new Share(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
UnfollowSchema,
|
UnfollowSchema,
|
||||||
} from "../schemas/follow.ts";
|
} from "../schemas/follow.ts";
|
||||||
import type { JSONObject } from "../types.ts";
|
import type { JSONObject } from "../types.ts";
|
||||||
import { Entity } from "./entity.ts";
|
import { Entity, Reference } from "./entity.ts";
|
||||||
|
|
||||||
export class Follow extends Entity {
|
export class Follow extends Entity {
|
||||||
public static override name = "Follow";
|
public static override name = "Follow";
|
||||||
|
|
@ -15,6 +15,14 @@ export class Follow extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Follow> {
|
||||||
return FollowSchema.parseAsync(json).then((u) => new Follow(u));
|
return FollowSchema.parseAsync(json).then((u) => new Follow(u));
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +37,14 @@ export class FollowAccept extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<FollowAccept> {
|
||||||
return FollowAcceptSchema.parseAsync(json).then(
|
return FollowAcceptSchema.parseAsync(json).then(
|
||||||
(u) => new FollowAccept(u),
|
(u) => new FollowAccept(u),
|
||||||
|
|
@ -45,6 +61,14 @@ export class FollowReject extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<FollowReject> {
|
||||||
return FollowRejectSchema.parseAsync(json).then(
|
return FollowRejectSchema.parseAsync(json).then(
|
||||||
(u) => new FollowReject(u),
|
(u) => new FollowReject(u),
|
||||||
|
|
@ -59,6 +83,14 @@ export class Unfollow extends Entity {
|
||||||
super(data);
|
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> {
|
public static override fromJSON(json: JSONObject): Promise<Unfollow> {
|
||||||
return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
|
return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export {
|
||||||
VideoContentFormat,
|
VideoContentFormat,
|
||||||
} from "./contentformat.ts";
|
} from "./contentformat.ts";
|
||||||
export { Delete } from "./delete.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 { Dislike, Like } from "./extensions/likes.ts";
|
||||||
export { Vote } from "./extensions/polls.ts";
|
export { Vote } from "./extensions/polls.ts";
|
||||||
export { Reaction } from "./extensions/reactions.ts";
|
export { Reaction } from "./extensions/reactions.ts";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { z } from "zod";
|
||||||
import { NoteSchema } from "../schemas/note.ts";
|
import { NoteSchema } from "../schemas/note.ts";
|
||||||
import type { JSONObject } from "../types.ts";
|
import type { JSONObject } from "../types.ts";
|
||||||
import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts";
|
import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts";
|
||||||
import { Entity } from "./entity.ts";
|
import { Entity, Reference } from "./entity.ts";
|
||||||
|
|
||||||
export class Note extends Entity {
|
export class Note extends Entity {
|
||||||
public static override name = "Note";
|
public static override name = "Note";
|
||||||
|
|
@ -15,6 +15,35 @@ export class Note extends Entity {
|
||||||
return NoteSchema.parseAsync(json).then((n) => new Note(n));
|
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[] {
|
public get attachments(): NonTextContentFormat[] {
|
||||||
return (
|
return (
|
||||||
this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? []
|
this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? []
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { sign } from "./crypto.ts";
|
import { sign } from "./crypto.ts";
|
||||||
import { Collection, URICollection } from "./entities/collection.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 { homepage, version } from "./package.json" with { type: "json" };
|
||||||
import { WebFingerSchema } from "./schemas/webfinger.ts";
|
import { WebFingerSchema } from "./schemas/webfinger.ts";
|
||||||
|
|
||||||
const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
|
const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
|
||||||
|
const CONTENT_TYPE = "application/vnd.versia+json";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A class that handles fetching Versia entities
|
* A class that handles fetching Versia entities
|
||||||
|
|
@ -22,22 +24,22 @@ const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`;
|
||||||
export class FederationRequester {
|
export class FederationRequester {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly privateKey: CryptoKey,
|
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,
|
url: URL,
|
||||||
expectedType: T,
|
entityType: T,
|
||||||
): Promise<InstanceType<T>> {
|
): Promise<InstanceType<T>> {
|
||||||
const req = new Request(url, {
|
const req = new Request(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: CONTENT_TYPE,
|
||||||
"User-Agent": DEFAULT_UA,
|
"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);
|
const res = await fetch(finalReq);
|
||||||
|
|
||||||
|
|
@ -49,79 +51,116 @@ export class FederationRequester {
|
||||||
|
|
||||||
const contentType = res.headers.get("Content-Type");
|
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(
|
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 jsonData = await res.json();
|
||||||
const type = jsonData.type;
|
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(
|
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>;
|
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, {
|
const req = new Request(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: CONTENT_TYPE,
|
||||||
"User-Agent": DEFAULT_UA,
|
"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()),
|
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);
|
return fetch(finalReq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively go through a Collection of entities until reaching the end
|
* Recursively go through a Collection of entities until reaching the end
|
||||||
* @param url URL to reach the Collection
|
* @param reference Entity Reference
|
||||||
* @param expectedType
|
* @param entityType
|
||||||
|
* @param collectionItemType
|
||||||
* @param options.limit Limit the number of entities to fetch
|
* @param options.limit Limit the number of entities to fetch
|
||||||
*/
|
*/
|
||||||
public async resolveCollection<T extends typeof Entity>(
|
public async resolveCollection<
|
||||||
url: URL,
|
E extends typeof Entity,
|
||||||
expectedType: T,
|
T extends typeof Entity,
|
||||||
|
>(
|
||||||
|
reference: Reference,
|
||||||
|
collectionName: string,
|
||||||
|
entityType: E,
|
||||||
|
collectionItemType: T,
|
||||||
options?: {
|
options?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
},
|
},
|
||||||
): Promise<InstanceType<T>[]> {
|
): 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>[] = [];
|
const entities: InstanceType<T>[] = [];
|
||||||
let nextUrl: URL | null = url;
|
|
||||||
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
while (nextUrl && limit > 0) {
|
let collection = await this.fetchSigned(url, Collection);
|
||||||
const collection: Collection = await this.fetchEntity(
|
const total = collection.data.total;
|
||||||
nextUrl,
|
|
||||||
Collection,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const entity of collection.data.items) {
|
while (collection && limit > 0) {
|
||||||
if (entity.type === expectedType.name) {
|
|
||||||
entities.push(
|
entities.push(
|
||||||
(await expectedType.fromJSON(
|
...collection.data.items.map(
|
||||||
entity,
|
(item) =>
|
||||||
)) as InstanceType<T>,
|
collectionItemType.fromJSON(item) as InstanceType<T>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
limit -= collection.data.items.length;
|
||||||
|
|
||||||
|
if (entities.length >= total) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextUrl = collection.data.next
|
url.searchParams.set("offset", entities.length.toString());
|
||||||
? new URL(collection.data.next)
|
collection = await this.fetchSigned(url, Collection);
|
||||||
: null;
|
|
||||||
limit -= collection.data.items.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entities;
|
return entities;
|
||||||
|
|
@ -129,33 +168,46 @@ export class FederationRequester {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively go through a URICollection of entities until reaching the end
|
* 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
|
* @param options.limit Limit the number of entities to fetch
|
||||||
*/
|
*/
|
||||||
public async resolveURICollection(
|
public async resolveURICollection<E extends typeof Entity>(
|
||||||
url: URL,
|
reference: Reference,
|
||||||
|
collectionName: string,
|
||||||
|
entityType: E,
|
||||||
options?: {
|
options?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
},
|
},
|
||||||
): Promise<URL[]> {
|
): Promise<string[]> {
|
||||||
const entities: string[] = [];
|
const url = new URL(
|
||||||
let nextUrl: URL | null = url;
|
`/.versia/v0.6/entities/${encodeURIComponent(
|
||||||
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
entityType.name,
|
||||||
|
)}/${encodeURIComponent(reference.id)}/collections/${encodeURIComponent(
|
||||||
while (nextUrl && limit > 0) {
|
collectionName,
|
||||||
const collection: URICollection = await this.fetchEntity(
|
)}`,
|
||||||
nextUrl,
|
`https://${reference.domain}`,
|
||||||
URICollection,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
entities.push(...collection.data.items);
|
const uris: string[] = [];
|
||||||
nextUrl = collection.data.next
|
let limit = options?.limit ?? Number.POSITIVE_INFINITY;
|
||||||
? new URL(collection.data.next)
|
|
||||||
: null;
|
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;
|
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(
|
public static async resolveWebFinger(
|
||||||
username: string,
|
username: string,
|
||||||
hostname: string,
|
domain: string,
|
||||||
contentType = "application/json",
|
contentType = "application/vnd.versia+json",
|
||||||
serverUrl = `https://${hostname}`,
|
serverUrl = `https://${domain}`,
|
||||||
): Promise<URL | null> {
|
): Promise<URL | null> {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
new URL(
|
new URL(
|
||||||
`/.well-known/webfinger?${new URLSearchParams({
|
`/.well-known/webfinger?${new URLSearchParams({
|
||||||
resource: `acct:${username}@${hostname}`,
|
resource: `acct:${username}@${domain}`,
|
||||||
})}`,
|
})}`,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/jrd+json, application/json",
|
||||||
"User-Agent": DEFAULT_UA,
|
"User-Agent": DEFAULT_UA,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -204,4 +256,57 @@ export class FederationRequester {
|
||||||
|
|
||||||
return new URL(selfLink.href);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
import { z } from "zod";
|
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({
|
export const CollectionSchema = z.strictObject({
|
||||||
author: url.nullable(),
|
author: ReferenceSchema.nullable(),
|
||||||
first: url,
|
|
||||||
last: url,
|
|
||||||
total: u64,
|
total: u64,
|
||||||
next: url.nullable(),
|
|
||||||
previous: url.nullable(),
|
|
||||||
items: z.array(z.any()),
|
items: z.array(z.any()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const URICollectionSchema = CollectionSchema.extend({
|
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 { z } from "zod";
|
||||||
import { f64, u64 } from "./common.ts";
|
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 allMimeTypes = Object.values(types) as [string, ...string[]];
|
||||||
const textMimeTypes = Object.values(types).filter((v) =>
|
const textMimeTypes = Object.values(types).filter((v) =>
|
||||||
v.startsWith("text/"),
|
v.startsWith("text/"),
|
||||||
|
|
@ -46,16 +26,7 @@ export const ContentFormatSchema = z.partialRecord(
|
||||||
remote: z.boolean(),
|
remote: z.boolean(),
|
||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
size: u64.nullish(),
|
size: u64.nullish(),
|
||||||
hash: z
|
hash: z.hash("sha256").nullish(),
|
||||||
.strictObject(
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(hashSizes).map(([k, v]) => [
|
|
||||||
k,
|
|
||||||
z.string().length(v).nullish(),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.nullish(),
|
|
||||||
thumbhash: z.string().nullish(),
|
thumbhash: z.string().nullish(),
|
||||||
width: u64.nullish(),
|
width: u64.nullish(),
|
||||||
height: u64.nullish(),
|
height: u64.nullish(),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "./common.ts";
|
import { ReferenceSchema, TransientEntitySchema } from "./entity.ts";
|
||||||
import { EntitySchema } from "./entity.ts";
|
|
||||||
|
|
||||||
export const DeleteSchema = EntitySchema.extend({
|
export const DeleteSchema = TransientEntitySchema.extend({
|
||||||
uri: z.null().optional(),
|
|
||||||
type: z.literal("Delete"),
|
type: z.literal("Delete"),
|
||||||
author: url.nullable(),
|
author: ReferenceSchema,
|
||||||
deleted_type: z.string(),
|
deleted_type: z.string(),
|
||||||
deleted: url,
|
deleted: ReferenceSchema,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isISOString } from "../regex.ts";
|
import { isISOString } from "../regex.ts";
|
||||||
import { url } from "./common.ts";
|
|
||||||
import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts";
|
import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts";
|
||||||
|
|
||||||
export const ExtensionPropertySchema = z
|
export const ExtensionPropertySchema = z
|
||||||
|
|
@ -10,14 +9,26 @@ export const ExtensionPropertySchema = z
|
||||||
})
|
})
|
||||||
.catchall(z.any());
|
.catchall(z.any());
|
||||||
|
|
||||||
|
export const ReferenceSchema = z.string();
|
||||||
|
|
||||||
export const EntitySchema = z.strictObject({
|
export const EntitySchema = z.strictObject({
|
||||||
// biome-ignore lint/style/useNamingConvention: required for JSON schema
|
// biome-ignore lint/style/useNamingConvention: required for JSON schema
|
||||||
$schema: z.url().nullish(),
|
$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
|
created_at: z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime"),
|
.refine((v) => isISOString(v), "must be a valid RFC 3339 datetime"),
|
||||||
uri: url,
|
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
extensions: ExtensionPropertySchema.nullish(),
|
extensions: ExtensionPropertySchema.nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TransientEntitySchema = EntitySchema.extend({
|
||||||
|
id: z.null().optional(),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,38 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "../common.ts";
|
|
||||||
import { TextContentFormatSchema } from "../contentformat.ts";
|
import { TextContentFormatSchema } from "../contentformat.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
import {
|
||||||
|
EntitySchema,
|
||||||
|
ReferenceSchema,
|
||||||
|
TransientEntitySchema,
|
||||||
|
} from "../entity.ts";
|
||||||
|
|
||||||
export const GroupSchema = EntitySchema.extend({
|
export const GroupSchema = EntitySchema.extend({
|
||||||
type: z.literal("pub.versia:groups/Group"),
|
type: z.literal("pub.versia:groups/Group"),
|
||||||
name: TextContentFormatSchema.nullish(),
|
name: TextContentFormatSchema.nullish(),
|
||||||
description: TextContentFormatSchema.nullish(),
|
description: TextContentFormatSchema.nullish(),
|
||||||
open: z.boolean().nullish(),
|
open: z.boolean(),
|
||||||
members: url,
|
|
||||||
notes: url.nullish(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GroupSubscribeSchema = EntitySchema.extend({
|
export const GroupSubscribeSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("pub.versia:groups/Subscribe"),
|
type: z.literal("pub.versia:groups/Subscribe"),
|
||||||
uri: z.null().optional(),
|
subscriber: ReferenceSchema,
|
||||||
subscriber: url,
|
group: ReferenceSchema,
|
||||||
group: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GroupUnsubscribeSchema = EntitySchema.extend({
|
export const GroupUnsubscribeSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("pub.versia:groups/Unsubscribe"),
|
type: z.literal("pub.versia:groups/Unsubscribe"),
|
||||||
uri: z.null().optional(),
|
subscriber: ReferenceSchema,
|
||||||
subscriber: url,
|
group: ReferenceSchema,
|
||||||
group: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GroupSubscribeAcceptSchema = EntitySchema.extend({
|
export const GroupSubscribeAcceptSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("pub.versia:groups/SubscribeAccept"),
|
type: z.literal("pub.versia:groups/SubscribeAccept"),
|
||||||
uri: z.null().optional(),
|
subscriber: ReferenceSchema,
|
||||||
subscriber: url,
|
group: ReferenceSchema,
|
||||||
group: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GroupSubscribeRejectSchema = EntitySchema.extend({
|
export const GroupSubscribeRejectSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("pub.versia:groups/SubscribeReject"),
|
type: z.literal("pub.versia:groups/SubscribeReject"),
|
||||||
uri: z.null().optional(),
|
subscriber: ReferenceSchema,
|
||||||
subscriber: url,
|
group: ReferenceSchema,
|
||||||
group: url,
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "../common.ts";
|
import { EntitySchema, ReferenceSchema } from "../entity.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
|
||||||
|
|
||||||
export const LikeSchema = EntitySchema.extend({
|
export const LikeSchema = EntitySchema.extend({
|
||||||
type: z.literal("pub.versia:likes/Like"),
|
type: z.literal("pub.versia:likes/Like"),
|
||||||
author: url,
|
author: ReferenceSchema,
|
||||||
liked: url,
|
liked: ReferenceSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DislikeSchema = EntitySchema.extend({
|
export const DislikeSchema = EntitySchema.extend({
|
||||||
type: z.literal("pub.versia:likes/Dislike"),
|
type: z.literal("pub.versia:likes/Dislike"),
|
||||||
author: url,
|
author: ReferenceSchema,
|
||||||
disliked: url,
|
disliked: ReferenceSchema,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "../common.ts";
|
import { ReferenceSchema, TransientEntitySchema } from "../entity.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
|
||||||
|
|
||||||
export const MigrationSchema = EntitySchema.extend({
|
export const MigrationSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("pub.versia:migration/Migration"),
|
type: z.literal("pub.versia:migration/Migration"),
|
||||||
uri: z.null().optional(),
|
author: ReferenceSchema,
|
||||||
author: url,
|
destination: ReferenceSchema,
|
||||||
destination: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MigrationExtensionSchema = z.strictObject({
|
export const MigrationExtensionSchema = z.strictObject({
|
||||||
previous: url,
|
previous: ReferenceSchema,
|
||||||
new: url.nullish(),
|
new: ReferenceSchema.nullish(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isISOString } from "../../regex.ts";
|
import { isISOString } from "../../regex.ts";
|
||||||
import { u64, url } from "../common.ts";
|
import { u64 } from "../common.ts";
|
||||||
import { TextContentFormatSchema } from "../contentformat.ts";
|
import { TextContentFormatSchema } from "../contentformat.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
import { EntitySchema, ReferenceSchema } from "../entity.ts";
|
||||||
|
|
||||||
export const VoteSchema = EntitySchema.extend({
|
export const VoteSchema = EntitySchema.extend({
|
||||||
type: z.literal("pub.versia:polls/Vote"),
|
type: z.literal("pub.versia:polls/Vote"),
|
||||||
author: url,
|
author: ReferenceSchema,
|
||||||
poll: url,
|
poll: ReferenceSchema,
|
||||||
option: u64,
|
option: u64,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -17,6 +17,6 @@ export const PollExtensionSchema = z.strictObject({
|
||||||
multiple_choice: z.boolean(),
|
multiple_choice: z.boolean(),
|
||||||
expires_at: z
|
expires_at: z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
|
.refine((v) => isISOString(v), "must be a valid RFC 3339 datetime")
|
||||||
.nullish(),
|
.nullish(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "../common.ts";
|
import { EntitySchema, ReferenceSchema } from "../entity.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
|
||||||
|
|
||||||
export const ReactionSchema = EntitySchema.extend({
|
export const ReactionSchema = EntitySchema.extend({
|
||||||
type: z.literal("pub.versia:reactions/Reaction"),
|
type: z.literal("pub.versia:reactions/Reaction"),
|
||||||
author: url,
|
author: ReferenceSchema,
|
||||||
object: url,
|
object: ReferenceSchema,
|
||||||
content: z.string().min(1).max(256),
|
content: z.string().min(1).max(256),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "../common.ts";
|
import { ReferenceSchema, TransientEntitySchema } from "../entity.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
|
||||||
|
|
||||||
export const ReportSchema = EntitySchema.extend({
|
export const ReportSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("pub.versia:reports/Report"),
|
type: z.literal("pub.versia:reports/Report"),
|
||||||
uri: z.null().optional(),
|
author: ReferenceSchema.nullish(),
|
||||||
author: url.nullish(),
|
reported: z.array(ReferenceSchema),
|
||||||
reported: z.array(url),
|
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
comment: z
|
comment: z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "../common.ts";
|
import { EntitySchema, ReferenceSchema } from "../entity.ts";
|
||||||
import { EntitySchema } from "../entity.ts";
|
|
||||||
|
|
||||||
export const ShareSchema = EntitySchema.extend({
|
export const ShareSchema = EntitySchema.extend({
|
||||||
type: z.literal("pub.versia:share/Share"),
|
type: z.literal("pub.versia:share/Share"),
|
||||||
author: url,
|
author: ReferenceSchema,
|
||||||
shared: url,
|
shared: ReferenceSchema,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ianaTimezoneRegex, isISOString } from "../../regex.ts";
|
import { ianaTimezoneRegex, isISOString } from "../../regex.ts";
|
||||||
import { url } from "../common.ts";
|
|
||||||
import {
|
import {
|
||||||
AudioContentFormatSchema,
|
AudioContentFormatSchema,
|
||||||
ImageContentFormatSchema,
|
ImageContentFormatSchema,
|
||||||
} from "../contentformat.ts";
|
} from "../contentformat.ts";
|
||||||
|
import { ReferenceSchema } from "../entity.ts";
|
||||||
|
|
||||||
export const VanityExtensionSchema = z.strictObject({
|
export const VanityExtensionSchema = z.strictObject({
|
||||||
avatar_overlays: z.array(ImageContentFormatSchema).nullish(),
|
avatar_overlays: z.array(ImageContentFormatSchema).nullish(),
|
||||||
|
|
@ -21,7 +21,6 @@ export const VanityExtensionSchema = z.strictObject({
|
||||||
pronouns: z.record(
|
pronouns: z.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
z.array(
|
z.array(
|
||||||
z.union([
|
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
subject: z.string(),
|
subject: z.string(),
|
||||||
object: z.string(),
|
object: z.string(),
|
||||||
|
|
@ -29,16 +28,14 @@ export const VanityExtensionSchema = z.strictObject({
|
||||||
independent_possessive: z.string(),
|
independent_possessive: z.string(),
|
||||||
reflexive: z.string(),
|
reflexive: z.string(),
|
||||||
}),
|
}),
|
||||||
z.string(),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
birthday: z
|
birthday: z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
|
.refine((v) => isISOString(v), "must be a valid RFC 3339 datetime")
|
||||||
.nullish(),
|
.nullish(),
|
||||||
location: z.string().nullish(),
|
location: z.string().nullish(),
|
||||||
aliases: z.array(url).nullish(),
|
aliases: z.array(ReferenceSchema).nullish(),
|
||||||
timezone: z
|
timezone: z
|
||||||
.string()
|
.string()
|
||||||
.regex(ianaTimezoneRegex, "must be a valid IANA timezone")
|
.regex(ianaTimezoneRegex, "must be a valid IANA timezone")
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,26 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "./common.ts";
|
import { ReferenceSchema, TransientEntitySchema } from "./entity.ts";
|
||||||
import { EntitySchema } from "./entity.ts";
|
|
||||||
|
|
||||||
export const FollowSchema = EntitySchema.extend({
|
export const FollowSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("Follow"),
|
type: z.literal("Follow"),
|
||||||
uri: z.null().optional(),
|
author: ReferenceSchema,
|
||||||
author: url,
|
followee: ReferenceSchema,
|
||||||
followee: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FollowAcceptSchema = EntitySchema.extend({
|
export const FollowAcceptSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("FollowAccept"),
|
type: z.literal("FollowAccept"),
|
||||||
uri: z.null().optional(),
|
author: ReferenceSchema,
|
||||||
author: url,
|
follower: ReferenceSchema,
|
||||||
follower: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FollowRejectSchema = EntitySchema.extend({
|
export const FollowRejectSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("FollowReject"),
|
type: z.literal("FollowReject"),
|
||||||
uri: z.null().optional(),
|
author: ReferenceSchema,
|
||||||
author: url,
|
follower: ReferenceSchema,
|
||||||
follower: url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UnfollowSchema = EntitySchema.extend({
|
export const UnfollowSchema = TransientEntitySchema.extend({
|
||||||
type: z.literal("Unfollow"),
|
type: z.literal("Unfollow"),
|
||||||
uri: z.null().optional(),
|
author: ReferenceSchema,
|
||||||
author: url,
|
followee: ReferenceSchema,
|
||||||
followee: url,
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ export {
|
||||||
VideoContentFormatSchema,
|
VideoContentFormatSchema,
|
||||||
} from "./contentformat.ts";
|
} from "./contentformat.ts";
|
||||||
export { DeleteSchema } from "./delete.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 { DislikeSchema, LikeSchema } from "./extensions/likes.ts";
|
||||||
export { VoteSchema } from "./extensions/polls.ts";
|
export { VoteSchema } from "./extensions/polls.ts";
|
||||||
export { ReactionSchema } from "./extensions/reactions.ts";
|
export { ReactionSchema } from "./extensions/reactions.ts";
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { extensionRegex, semverRegex } from "../regex.ts";
|
import { extensionRegex, semverRegex } from "../regex.ts";
|
||||||
import { url } from "./common.ts";
|
|
||||||
import { ImageContentFormatSchema } from "./contentformat.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"),
|
type: z.literal("InstanceMetadata"),
|
||||||
id: z.null().optional(),
|
|
||||||
uri: z.null().optional(),
|
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
software: z.strictObject({
|
software: z.strictObject({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|
@ -28,14 +25,11 @@ export const InstanceMetadataSchema = EntitySchema.extend({
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
host: z.string(),
|
domain: z.string(),
|
||||||
shared_inbox: url.nullish(),
|
|
||||||
public_key: z.strictObject({
|
public_key: z.strictObject({
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
algorithm: z.literal("ed25519"),
|
algorithm: z.literal("ed25519"),
|
||||||
}),
|
}),
|
||||||
moderators: url.nullish(),
|
|
||||||
admins: url.nullish(),
|
|
||||||
logo: ImageContentFormatSchema.nullish(),
|
logo: ImageContentFormatSchema.nullish(),
|
||||||
banner: ImageContentFormatSchema.nullish(),
|
banner: ImageContentFormatSchema.nullish(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import {
|
||||||
NonTextContentFormatSchema,
|
NonTextContentFormatSchema,
|
||||||
TextContentFormatSchema,
|
TextContentFormatSchema,
|
||||||
} from "./contentformat.ts";
|
} from "./contentformat.ts";
|
||||||
import { EntitySchema } from "./entity.ts";
|
import { EntitySchema, ReferenceSchema } from "./entity.ts";
|
||||||
import { PollExtensionSchema } from "./extensions/polls.ts";
|
import { PollExtensionSchema } from "./extensions/polls.ts";
|
||||||
|
|
||||||
export const NoteSchema = EntitySchema.extend({
|
export const NoteSchema = EntitySchema.extend({
|
||||||
type: z.literal("Note"),
|
type: z.literal("Note"),
|
||||||
attachments: z.array(NonTextContentFormatSchema).nullish(),
|
attachments: z.array(NonTextContentFormatSchema),
|
||||||
author: url,
|
author: ReferenceSchema,
|
||||||
category: z
|
category: z
|
||||||
.enum([
|
.enum([
|
||||||
"microblog",
|
"microblog",
|
||||||
|
|
@ -23,16 +23,6 @@ export const NoteSchema = EntitySchema.extend({
|
||||||
])
|
])
|
||||||
.nullish(),
|
.nullish(),
|
||||||
content: TextContentFormatSchema.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
|
device: z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|
@ -40,11 +30,10 @@ export const NoteSchema = EntitySchema.extend({
|
||||||
url: url.nullish(),
|
url: url.nullish(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
group: url.or(z.enum(["public", "followers"])).nullish(),
|
group: ReferenceSchema.or(z.enum(["public", "followers"])).nullish(),
|
||||||
is_sensitive: z.boolean().nullish(),
|
is_sensitive: z.boolean(),
|
||||||
mentions: z.array(url).nullish(),
|
mentions: z.array(ReferenceSchema),
|
||||||
previews: z
|
previews: z.array(
|
||||||
.array(
|
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
link: url,
|
link: url,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
|
@ -52,10 +41,9 @@ export const NoteSchema = EntitySchema.extend({
|
||||||
image: url.nullish(),
|
image: url.nullish(),
|
||||||
icon: url.nullish(),
|
icon: url.nullish(),
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.nullish(),
|
quotes: ReferenceSchema.nullish(),
|
||||||
quotes: url.nullish(),
|
replies_to: ReferenceSchema.nullish(),
|
||||||
replies_to: url.nullish(),
|
|
||||||
subject: z.string().nullish(),
|
subject: z.string().nullish(),
|
||||||
extensions: EntitySchema.shape.extensions
|
extensions: EntitySchema.shape.extensions
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { url } from "./common.ts";
|
|
||||||
import {
|
import {
|
||||||
ImageContentFormatSchema,
|
ImageContentFormatSchema,
|
||||||
TextContentFormatSchema,
|
TextContentFormatSchema,
|
||||||
|
|
@ -8,25 +7,17 @@ import { EntitySchema } from "./entity.ts";
|
||||||
import { MigrationExtensionSchema } from "./extensions/migration.ts";
|
import { MigrationExtensionSchema } from "./extensions/migration.ts";
|
||||||
import { VanityExtensionSchema } from "./extensions/vanity.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({
|
export const UserSchema = EntitySchema.extend({
|
||||||
type: z.literal("User"),
|
type: z.literal("User"),
|
||||||
avatar: ImageContentFormatSchema.nullish(),
|
avatar: ImageContentFormatSchema.nullish(),
|
||||||
bio: TextContentFormatSchema.nullish(),
|
bio: TextContentFormatSchema.nullish(),
|
||||||
display_name: z.string().nullish(),
|
display_name: z.string().nullish(),
|
||||||
fields: z
|
fields: z.array(
|
||||||
.array(
|
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
key: TextContentFormatSchema,
|
key: TextContentFormatSchema,
|
||||||
value: TextContentFormatSchema,
|
value: TextContentFormatSchema,
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.nullish(),
|
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
|
|
@ -35,20 +26,8 @@ export const UserSchema = EntitySchema.extend({
|
||||||
"must be alphanumeric, and may contain _ or -",
|
"must be alphanumeric, and may contain _ or -",
|
||||||
),
|
),
|
||||||
header: ImageContentFormatSchema.nullish(),
|
header: ImageContentFormatSchema.nullish(),
|
||||||
public_key: PublicKeyDataSchema,
|
manually_approves_followers: z.boolean(),
|
||||||
manually_approves_followers: z.boolean().nullish(),
|
indexable: z.boolean(),
|
||||||
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),
|
|
||||||
extensions: EntitySchema.shape.extensions
|
extensions: EntitySchema.shape.extensions
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface ApiRouteExports {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KnownEntity =
|
export type KnownEntity =
|
||||||
|
| VersiaEntities.Entity
|
||||||
| VersiaEntities.Note
|
| VersiaEntities.Note
|
||||||
| VersiaEntities.InstanceMetadata
|
| VersiaEntities.InstanceMetadata
|
||||||
| VersiaEntities.User
|
| VersiaEntities.User
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue