mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor: 🚚 Move more utilities into packages
This commit is contained in:
parent
5cae547f8d
commit
3798e170d0
140 changed files with 913 additions and 735 deletions
438
packages/plugin-kit/api.ts
Normal file
438
packages/plugin-kit/api.ts
Normal 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}`;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
35
packages/plugin-kit/markdown.ts
Normal file
35
packages/plugin-kit/markdown.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
packages/plugin-kit/parsers.ts
Normal file
169
packages/plugin-kit/parsers.ts
Normal 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 };
|
||||
};
|
||||
10
packages/plugin-kit/redis.ts
Normal file
10
packages/plugin-kit/redis.ts
Normal 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,
|
||||
});
|
||||
49
packages/plugin-kit/regex.ts
Normal file
49
packages/plugin-kit/regex.ts
Normal 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"),
|
||||
),
|
||||
[],
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue