refactor: 🚚 Move more utilities into packages

This commit is contained in:
Jesse Wierzbinski 2025-06-15 23:43:27 +02:00
parent 5cae547f8d
commit 3798e170d0
No known key found for this signature in database
140 changed files with 913 additions and 735 deletions

438
packages/plugin-kit/api.ts Normal file
View file

@ -0,0 +1,438 @@
import type { Hook } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape";
import type { RolePermission } from "@versia/client/schemas";
import { config } from "@versia-server/config";
import { extractParams, verifySolution } from "altcha-lib";
import chalk from "chalk";
import { eq, type SQL } from "drizzle-orm";
import type { Context, Hono, MiddlewareHandler, ValidationTargets } from "hono";
import { every } from "hono/combine";
import { createMiddleware } from "hono/factory";
import { validator } from "hono-openapi/zod";
import { type ParsedQs, parse } from "qs";
import { type ZodAny, type ZodError, z } from "zod";
import { fromZodError } from "zod-validation-error";
import type { AuthData, HonoEnv } from "~/types/api";
import { ApiError } from "./api-error.ts";
import { Application } from "./db/application.ts";
import { Emoji } from "./db/emoji.ts";
import { Note } from "./db/note.ts";
import { Token } from "./db/token.ts";
import { User } from "./db/user.ts";
import { db } from "./tables/db.ts";
import { Challenges } from "./tables/schema.ts";
export const apiRoute = (fn: (app: Hono<HonoEnv>) => void): typeof fn => fn;
export const handleZodError: Hook<
z.infer<ZodAny>,
HonoEnv,
string,
keyof ValidationTargets
> = (result, context): Response | undefined => {
if (!result.success) {
return context.json(
{
error: fromZodError(result.error as ZodError).message,
},
422,
);
}
return undefined;
};
const checkPermissions = (
auth: AuthData | null,
required: RolePermission[],
): void => {
const userPerms = auth?.user
? auth.user.getAllPermissions()
: config.permissions.anonymous;
if (!required.every((perm) => userPerms.includes(perm))) {
const missingPerms = required.filter(
(perm) => !userPerms.includes(perm),
);
throw new ApiError(
403,
"Missing permissions",
`Missing: ${missingPerms.join(", ")}`,
);
}
};
const checkRouteNeedsAuth = (
auth: AuthData | null,
required: boolean,
): AuthData => {
if (auth?.user && auth?.token) {
return {
user: auth.user,
token: auth.token,
application: auth.application,
};
}
if (required) {
throw new ApiError(401, "This route requires authentication");
}
return {
user: null,
token: null,
application: null,
};
};
export const checkRouteNeedsChallenge = async (
required: boolean,
context: Context,
): Promise<void> => {
if (!(required && config.validation.challenges)) {
return;
}
const challengeSolution = context.req.header("X-Challenge-Solution");
if (!challengeSolution) {
throw new ApiError(
401,
"Challenge required",
"This route requires a challenge solution to be sent to it via the X-Challenge-Solution header. Please check the documentation for more information.",
);
}
const { challenge_id } = extractParams(challengeSolution);
if (!challenge_id) {
throw new ApiError(401, "The challenge solution provided is invalid.");
}
const challenge = await db.query.Challenges.findFirst({
where: (c): SQL | undefined => eq(c.id, challenge_id),
});
if (!challenge) {
throw new ApiError(401, "The challenge solution provided is invalid.");
}
if (new Date(challenge.expiresAt) < new Date()) {
throw new ApiError(401, "The challenge provided has expired.");
}
const isValid = await verifySolution(
challengeSolution,
config.validation.challenges.key,
);
if (!isValid) {
throw new ApiError(
401,
"The challenge solution provided is incorrect.",
);
}
// Expire the challenge
await db
.update(Challenges)
.set({ expiresAt: new Date().toISOString() })
.where(eq(Challenges.id, challenge_id));
};
export type HonoEnvWithAuth = HonoEnv & {
Variables: {
auth: AuthData & {
user: NonNullable<AuthData["user"]>;
token: NonNullable<AuthData["token"]>;
};
};
};
export const auth = <AuthRequired extends boolean>(options: {
auth: AuthRequired;
permissions?: RolePermission[];
challenge?: boolean;
scopes?: string[];
// If authRequired is true, HonoEnv.Variables.auth.user will never be null
}): MiddlewareHandler<
AuthRequired extends true ? HonoEnvWithAuth : HonoEnv
> => {
return createMiddleware(async (context, next) => {
const header = context.req.header("Authorization");
const tokenString = header?.split(" ")[1];
const token = tokenString
? await Token.fromAccessToken(tokenString)
: null;
const auth: AuthData = {
token,
application: token?.data.application
? new Application(token?.data.application)
: null,
user: (await token?.getUser()) ?? null,
};
// Authentication check
const authCheck = checkRouteNeedsAuth(auth, options.auth);
context.set("auth", authCheck);
// Permissions check
if (options.permissions) {
checkPermissions(auth, options.permissions);
}
// Challenge check
if (options.challenge && config.validation.challenges) {
await checkRouteNeedsChallenge(options.challenge, context);
}
await next();
});
};
type WithIdParam = {
in: { param: { id: string } };
out: { param: { id: string } };
};
/**
* Middleware to check if a note exists and is viewable by the user.
*
* Useful in /api/v1/statuses/:id/* routes
* @returns MiddlewareHandler
*/
export const withNoteParam = every(
validator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
note: Note;
};
},
string,
WithIdParam
>(async (context, next) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const note = await Note.fromId(id, user?.id);
if (!(note && (await note.isViewableByUser(user)))) {
throw ApiError.noteNotFound();
}
context.set("note", note);
await next();
}),
) as MiddlewareHandler<
HonoEnv & {
Variables: {
note: Note;
};
}
>;
/**
* Middleware to check if a user exists
*
* Useful in /api/v1/accounts/:id/* routes
* @returns MiddlewareHandler
*/
export const withUserParam = every(
validator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
user: User;
};
},
string,
WithIdParam
>(async (context, next) => {
const { id } = context.req.valid("param");
const user = await User.fromId(id);
if (!user) {
throw new ApiError(404, "User not found");
}
context.set("user", user);
await next();
}),
) as MiddlewareHandler<
HonoEnv & {
Variables: {
user: User;
};
}
>;
/**
* Middleware to check if an emoji exists and is viewable by the user
*
* Useful in /api/v1/emojis/:id/* routes
* @returns
*/
export const withEmojiParam = every(
validator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
emoji: Emoji;
};
},
string,
WithIdParam
>(async (context, next) => {
const { id } = context.req.valid("param");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw ApiError.emojiNotFound();
}
context.set("emoji", emoji);
await next();
}),
) as MiddlewareHandler<
HonoEnv & {
Variables: {
emoji: Emoji;
};
}
>;
// Helper function to parse form data
async function parseFormData(context: Context): Promise<{
parsed: ParsedQs;
files: Map<string, File>;
}> {
const formData = await context.req.formData();
const urlparams = new URLSearchParams();
const files = new Map<string, File>();
for (const [key, value] of [
...(formData.entries() as IterableIterator<[string, string | File]>),
]) {
if (Array.isArray(value)) {
for (const val of value) {
urlparams.append(key, val);
}
} else if (value instanceof File) {
if (!files.has(key)) {
files.set(key, value);
}
} else {
urlparams.append(key, String(value));
}
}
const parsed = parse(urlparams.toString(), {
parseArrays: true,
interpretNumericEntities: true,
});
return {
parsed,
files,
};
}
// Helper function to parse urlencoded data
async function parseUrlEncoded(context: Context): Promise<ParsedQs> {
const parsed = parse(await context.req.text(), {
parseArrays: true,
interpretNumericEntities: true,
});
return parsed;
}
export const qsQuery = (): MiddlewareHandler<HonoEnv> => {
return createMiddleware<HonoEnv>(async (context, next) => {
const parsed = parse(new URL(context.req.url).searchParams.toString(), {
parseArrays: true,
interpretNumericEntities: true,
});
// @ts-expect-error Very bad hack
context.req.query = (): typeof parsed => parsed;
// @ts-expect-error I'm so sorry for this
context.req.queries = (): typeof parsed => parsed;
await next();
});
};
export const setContextFormDataToObject = (
context: Context,
setTo: object,
): Context => {
context.req.bodyCache.json = setTo;
context.req.parseBody = (): Promise<unknown> =>
Promise.resolve(context.req.bodyCache.json);
// biome-ignore lint/suspicious/noExplicitAny: Monkeypatching
context.req.json = (): Promise<any> =>
Promise.resolve(context.req.bodyCache.json);
return context;
};
/*
* Middleware to magically unfuck forms
* Add it to random Hono routes and hope it works
* @returns
*/
export const jsonOrForm = (): MiddlewareHandler<HonoEnv> => {
return createMiddleware(async (context, next) => {
const contentType = context.req.header("content-type");
if (contentType?.includes("application/json")) {
setContextFormDataToObject(context, await context.req.json());
} else if (contentType?.includes("application/x-www-form-urlencoded")) {
const parsed = await parseUrlEncoded(context);
setContextFormDataToObject(context, parsed);
context.req.raw.headers.set("Content-Type", "application/json");
} else if (contentType?.includes("multipart/form-data")) {
const { parsed, files } = await parseFormData(context);
setContextFormDataToObject(context, {
...parsed,
...Object.fromEntries(files),
});
context.req.raw.headers.set("Content-Type", "application/json");
} else if (!contentType) {
setContextFormDataToObject(context, {});
context.req.raw.headers.set("Content-Type", "application/json");
}
await next();
});
};
export const debugResponse = async (res: Response): Promise<void> => {
const body = await res.clone().text();
const logger = getLogger("server");
const status = `${chalk.bold("Status")}: ${chalk.green(res.status)}`;
const headers = `${chalk.bold("Headers")}:\n${Array.from(
res.headers.entries(),
)
.map(([key, value]) => ` - ${chalk.cyan(key)}: ${chalk.white(value)}`)
.join("\n")}`;
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
if (config.logging.types.requests_content) {
logger.debug`${status}\n${headers}\n${bodyLog}`;
} else {
logger.debug`${status}`;
}
};

View file

@ -1,5 +1,7 @@
import type { NoteReactionWithAccounts, Status } from "@versia/client/schemas";
import { db, Instance, type Reaction } from "@versia/kit/db";
import { versiaTextToHtml } from "@versia/kit/parsers";
import { uuid } from "@versia/kit/regex";
import {
EmojiToNote,
Likes,
@ -26,10 +28,8 @@ import {
import { htmlToText } from "html-to-text";
import { createRegExp, exactly, global } from "magic-regexp";
import type { z } from "zod";
import { idValidator } from "@/api";
import { mergeAndDeduplicate } from "@/lib.ts";
import { sanitizedHtmlStrip } from "@/sanitization";
import { contentToHtml, findManyNotes } from "~/classes/functions/status";
import {
DeliveryJobType,
deliveryQueue,
@ -38,7 +38,188 @@ import { Application } from "./application.ts";
import { BaseInterface } from "./base.ts";
import { Emoji } from "./emoji.ts";
import { Media } from "./media.ts";
import { User } from "./user.ts";
import {
transformOutputToUserWithRelations,
User,
userRelations,
} from "./user.ts";
/**
* Wrapper against the Status object to make it easier to work with
* @param query
* @returns
*/
const findManyNotes = async (
query: Parameters<typeof db.query.Notes.findMany>[0],
userId?: string,
): Promise<(typeof Note.$type)[]> => {
const output = await db.query.Notes.findMany({
...query,
with: {
...query?.with,
attachments: {
with: {
media: true,
},
},
reactions: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
emojis: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
author: {
with: {
...userRelations,
},
},
mentions: {
with: {
user: {
with: {
instance: true,
},
},
},
},
reblog: {
with: {
attachments: {
with: {
media: true,
},
},
reactions: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
emojis: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
},
},
},
author: {
with: {
...userRelations,
},
},
},
extras: {
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
},
},
reply: true,
quote: true,
},
extras: {
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
...query?.extras,
},
});
return output.map((post) => ({
...post,
author: transformOutputToUserWithRelations(post.author),
mentions: post.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
attachments: post.attachments.map((attachment) => attachment.media),
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
attachments: post.reblog.attachments.map(
(attachment) => attachment.media,
),
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
pinned: Boolean(post.reblog.pinned),
reblogged: Boolean(post.reblog.reblogged),
muted: Boolean(post.reblog.muted),
liked: Boolean(post.reblog.liked),
},
pinned: Boolean(post.pinned),
reblogged: Boolean(post.reblogged),
muted: Boolean(post.muted),
liked: Boolean(post.liked),
}));
};
type NoteType = InferSelectModel<typeof Notes>;
@ -423,15 +604,15 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
// Check if URI is of a local note
if (uri.origin === config.http.base_url.origin) {
const uuid = uri.pathname.match(idValidator);
const noteUuid = uri.pathname.match(uuid);
if (!uuid?.[0]) {
if (!noteUuid?.[0]) {
throw new Error(
`URI ${uri} is of a local note, but it could not be parsed`,
);
}
return await Note.fromId(uuid[0]);
return await Note.fromId(noteUuid[0]);
}
return Note.fromVersia(uri);
@ -535,7 +716,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
await note.update({
content: versiaNote.content
? await contentToHtml(versiaNote.content, mentions)
? await versiaTextToHtml(versiaNote.content, mentions)
: undefined,
contentSource: versiaNote.content
? versiaNote.content.data["text/plain"]?.content ||

View file

@ -10,11 +10,8 @@ import {
type SQL,
} from "drizzle-orm";
import type { z } from "zod";
import {
transformOutputToUserWithRelations,
userRelations,
} from "../../../classes/functions/user.ts";
import { BaseInterface } from "./base.ts";
import { transformOutputToUserWithRelations, userRelations } from "./user.ts";
export type NotificationType = InferSelectModel<typeof Notifications> & {
status: typeof Note.$type | null;

View file

@ -13,6 +13,7 @@ import {
PushSubscription,
Reaction,
} from "@versia/kit/db";
import { uuid } from "@versia/kit/regex";
import {
EmojiToUser,
Likes,
@ -46,11 +47,9 @@ import {
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type { z } from "zod";
import { idValidator } from "@/api";
import { getBestContentType } from "@/content_types";
import { randomString } from "@/math";
import { sentry } from "@/sentry";
import { findManyUsers } from "~/classes/functions/user";
import { searchManager } from "~/classes/search/search-manager";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import {
@ -66,6 +65,89 @@ import { Note } from "./note.ts";
import { Relationship } from "./relationship.ts";
import { Role } from "./role.ts";
export const userRelations = {
instance: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
avatar: true,
header: true,
roles: {
with: {
role: true,
},
},
} as const;
export const transformOutputToUserWithRelations = (
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
avatar: typeof Media.$type | null;
header: typeof Media.$type | null;
emojis: {
userId: string;
emojiId: string;
emoji?: typeof Emoji.$type;
}[];
instance: typeof Instance.$type | null;
roles: {
userId: string;
roleId: string;
role?: typeof Role.$type;
}[];
endpoints: unknown;
},
): typeof User.$type => {
return {
...user,
followerCount: Number(user.followerCount),
followingCount: Number(user.followingCount),
statusCount: Number(user.statusCount),
endpoints:
user.endpoints ??
({} as Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>),
emojis: user.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as typeof Emoji.$type,
),
roles: user.roles
.map((role) => role.role)
.filter(Boolean) as (typeof Role.$type)[],
};
};
const findManyUsers = async (
query: Parameters<typeof db.query.Users.findMany>[0],
): Promise<(typeof User.$type)[]> => {
const output = await db.query.Users.findMany({
...query,
with: {
...userRelations,
...query?.with,
},
});
return output.map((user) => transformOutputToUserWithRelations(user));
};
type UserWithInstance = InferSelectModel<typeof Users> & {
instance: typeof Instance.$type | null;
};
@ -1094,15 +1176,15 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
// Check if URI is of a local user
if (uri.origin === config.http.base_url.origin) {
const uuid = uri.href.match(idValidator);
const userUuid = uri.href.match(uuid);
if (!uuid?.[0]) {
if (!userUuid?.[0]) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
}
return await User.fromId(uuid[0]);
return await User.fromId(userUuid[0]);
}
getLogger(["federation", "resolvers"])

View file

@ -0,0 +1,35 @@
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import MarkdownIt from "markdown-it";
import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
const createMarkdownIt = (): MarkdownIt => {
const renderer = MarkdownIt({
html: true,
linkify: true,
});
renderer.use(markdownItTocDoneRight, {
containerClass: "toc",
level: [1, 2, 3, 4],
listType: "ul",
listClass: "toc-list",
itemClass: "toc-item",
linkClass: "toc-link",
});
renderer.use(markdownItTaskLists);
renderer.use(markdownItContainer);
return renderer;
};
/**
* Converts markdown text to HTML using MarkdownIt.
* @param content
* @returns
*/
export const markdownToHtml = async (content: string): Promise<string> => {
return (await createMarkdownIt()).render(content);
};

View file

@ -47,7 +47,15 @@
"sharp": "catalog:",
"magic-regexp": "catalog:",
"altcha-lib": "catalog:",
"hono-openapi": "catalog:"
"hono-openapi": "catalog:",
"qs": "catalog:",
"@hono/zod-validator": "catalog:",
"ioredis": "catalog:",
"linkify-html": "catalog:",
"markdown-it": "catalog:",
"markdown-it-toc-done-right": "catalog:",
"markdown-it-container": "catalog:",
"@hackmd/markdown-it-task-lists": "catalog:"
},
"files": [
"tables/migrations"
@ -64,6 +72,26 @@
"./tables": {
"import": "./tables/schema.ts",
"default": "./tables/schema.ts"
},
"./api": {
"import": "./api.ts",
"default": "./api.ts"
},
"./redis": {
"import": "./redis.ts",
"default": "./redis.ts"
},
"./regex": {
"import": "./regex.ts",
"default": "./regex.ts"
},
"./markdown": {
"import": "./markdown.ts",
"default": "./markdown.ts"
},
"./parsers": {
"import": "./parsers.ts",
"default": "./parsers.ts"
}
}
}

View file

@ -0,0 +1,169 @@
import type * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import linkifyHtml from "linkify-html";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
global,
letter,
} from "magic-regexp";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import { User } from "./db/user.ts";
import { markdownToHtml } from "./markdown.ts";
import { mention } from "./regex.ts";
import { db } from "./tables/db.ts";
import { Instances, Users } from "./tables/schema.ts";
/**
* Get people mentioned in the content (match @username or @username@domain.com mentions)
* @param text The text to parse mentions from.
* @returns An array of users mentioned in the text.
*/
export const parseMentionsFromText = async (text: string): Promise<User[]> => {
const mentionedPeople = [...text.matchAll(mention)];
if (mentionedPeople.length === 0) {
return [];
}
const baseUrlHost = config.http.base_url.host;
const isLocal = (host?: string): boolean => host === baseUrlHost || !host;
// Find local and matching users
const foundUsers = await db
.select({
id: Users.id,
username: Users.username,
baseUrl: Instances.baseUrl,
})
.from(Users)
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
.where(
or(
...mentionedPeople.map((person) =>
and(
eq(Users.username, person[1] ?? ""),
isLocal(person[2])
? isNull(Users.instanceId)
: eq(Instances.baseUrl, person[2] ?? ""),
),
),
),
);
// Separate found and unresolved users
const finalList = await User.manyFromSql(
inArray(
Users.id,
foundUsers.map((u) => u.id),
),
);
// Every remote user that isn't in database
const notFoundRemoteUsers = mentionedPeople.filter(
(p) =>
!(
foundUsers.some(
(user) => user.username === p[1] && user.baseUrl === p[2],
) || isLocal(p[2])
),
);
// Resolve remote mentions not in database
for (const person of notFoundRemoteUsers) {
const url = await FederationRequester.resolveWebFinger(
person[1] ?? "",
person[2] ?? "",
);
if (url) {
const user = await User.resolve(url);
if (user) {
finalList.push(user);
}
}
}
return finalList;
};
export const linkifyUserMentions = (text: string, mentions: User[]): string => {
return mentions.reduce((finalText, mention) => {
const { username, instance } = mention.data;
const { uri } = mention;
const baseHost = config.http.base_url.host;
const linkTemplate = (displayText: string): string =>
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
if (mention.remote) {
return finalText.replaceAll(
`@${username}@${instance?.baseUrl}`,
linkTemplate(`@${username}@${instance?.baseUrl}`),
);
}
return finalText.replace(
createRegExp(
exactly(
exactly(`@${username}`)
.notBefore(anyOf(letter, digit, charIn("@")))
.notAfter(anyOf(letter, digit, charIn("@"))),
).or(exactly(`@${username}@${baseHost}`)),
[global],
),
linkTemplate(`@${username}@${baseHost}`),
);
}, text);
};
export const versiaTextToHtml = async (
content: VersiaEntities.TextContentFormat,
mentions: User[] = [],
inline = false,
): Promise<string> => {
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
let htmlContent = "";
if (content.data["text/html"]) {
htmlContent = await sanitizer(content.data["text/html"].content);
} else if (content.data["text/markdown"]) {
htmlContent = await sanitizer(
await markdownToHtml(content.data["text/markdown"].content),
);
} else if (content.data["text/plain"]?.content) {
htmlContent = (await sanitizer(content.data["text/plain"].content))
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");
}
htmlContent = linkifyUserMentions(htmlContent, mentions);
return linkifyHtml(htmlContent, {
defaultProtocol: "https",
validate: { email: (): false => false },
target: "_blank",
rel: "nofollow noopener noreferrer",
});
};
export const parseUserAddress = (
address: string,
): {
username: string;
domain?: string;
} => {
let output = address;
// Remove leading @ if it exists
if (output.startsWith("@")) {
output = output.slice(1);
}
const [username, domain] = output.split("@");
return { username, domain };
};

View file

@ -0,0 +1,10 @@
import { config } from "@versia-server/config";
import IORedis from "ioredis";
export const connection = new IORedis({
host: config.redis.queue.host,
port: config.redis.queue.port,
password: config.redis.queue.password,
db: config.redis.queue.database,
maxRetriesPerRequest: null,
});

View file

@ -0,0 +1,49 @@
import {
anyOf,
caseInsensitive,
charIn,
createRegExp,
digit,
exactly,
global,
letter,
maybe,
oneOrMore,
} from "magic-regexp";
export const uuid = createRegExp(
anyOf(digit, charIn("ABCDEF")).times(8),
exactly("-"),
anyOf(digit, charIn("ABCDEF")).times(4),
exactly("-"),
exactly("7"),
anyOf(digit, charIn("ABCDEF")).times(3),
exactly("-"),
anyOf("8", "9", "A", "B").times(1),
anyOf(digit, charIn("ABCDEF")).times(3),
exactly("-"),
anyOf(digit, charIn("ABCDEF")).times(12),
[caseInsensitive],
);
export const mention = createRegExp(
exactly("@"),
oneOrMore(anyOf(letter.lowercase, digit, charIn("-_"))).groupedAs(
"username",
),
maybe(
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs("domain"),
),
[global],
);
export const webfingerMention = createRegExp(
exactly("acct:"),
oneOrMore(anyOf(letter, digit, charIn("-_"))).groupedAs("username"),
maybe(
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs("domain"),
),
[],
);