Finish full rewrite of server and testing systems

This commit is contained in:
Jesse Wierzbinski 2024-03-10 16:04:14 -10:00
parent 0e4d6b401c
commit 0541776d3d
No known key found for this signature in database
32 changed files with 1168 additions and 916 deletions

View file

@ -272,6 +272,8 @@ emoji_filters = [] # NOT IMPLEMENTED
log_requests = true
# Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false
# For GDPR compliance, you can disable logging of IPs
log_ip = false
# Log all filtered objects
log_filters = true

View file

@ -1,8 +1,8 @@
import { Queue } from "bullmq";
import { getConfig } from "../utils/config";
import { PrismaClient } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const client = new PrismaClient({
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,

View file

@ -95,25 +95,3 @@ export const emojiToActivityPub = (emoji: Emoji): any => {
},
};
};
export const addAPEmojiIfNotExists = async (apEmoji: any) => {
// replace any with your ActivityPub Emoji type
const existingEmoji = await client.emoji.findFirst({
where: {
shortcode: apEmoji.name.replace(/:/g, ""),
instance: null,
},
});
if (existingEmoji) return existingEmoji;
return await client.emoji.create({
data: {
shortcode: apEmoji.name.replace(/:/g, ""),
url: apEmoji.icon.url,
alt: apEmoji.icon.alt || null,
content_type: apEmoji.icon.mediaType,
visible_in_picker: true,
},
});
};

View file

@ -1,10 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Like as LysandLike } from "~types/lysand/Object";
import { getConfig } from "~classes/configmanager";
import type { Like } from "@prisma/client";
import { client } from "~database/datasource";
import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
/**
* Represents a Like entity in the database.
@ -16,7 +18,7 @@ export const toLysand = (like: Like): LysandLike => {
type: "Like",
created_at: new Date(like.createdAt).toISOString(),
object: (like as any).liked?.uri,
uri: `${getConfig().http.base_url}/actions/${like.id}`,
uri: `${config.http.base_url}/actions/${like.id}`,
};
};

View file

@ -1,4 +1,3 @@
import { getConfig } from "~classes/configmanager";
import { Worker } from "bullmq";
import { client, federationQueue } from "~database/datasource";
import {
@ -7,8 +6,9 @@ import {
type StatusWithRelations,
} from "./Status";
import type { User } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
export const federationWorker = new Worker(
"federation",

View file

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { getConfig } from "~classes/configmanager";
import type { UserWithRelations } from "./User";
import {
fetchRemoteUser,
@ -29,8 +28,9 @@ import { parse } from "marked";
import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
export const statusAndUserRelations: Prisma.StatusInclude = {
author: {
@ -349,7 +349,9 @@ export const createNewStatus = async (data: {
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content)));
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content))
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
@ -480,7 +482,9 @@ export const editStatus = async (
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content)));
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content))
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
@ -612,9 +616,9 @@ export const statusToAPI = async (
};
};
export const statusToActivityPub = async (
status: StatusWithRelations,
user?: UserWithRelations
/* export const statusToActivityPub = async (
status: StatusWithRelations
// user?: UserWithRelations
): Promise<any> => {
// replace any with your ActivityPub type
return {
@ -657,7 +661,7 @@ export const statusToActivityPub = async (
visibility: "public", // adjust as needed
// add more fields as needed
};
};
}; */
export const statusToLysand = (status: StatusWithRelations): Note => {
return {

View file

@ -1,5 +1,3 @@
import type { ConfigType } from "~classes/configmanager";
import { getConfig } from "~classes/configmanager";
import type { APIAccount } from "~types/entities/account";
import type { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
@ -10,6 +8,10 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch";
import { ConfigManager, type ConfigType } from "config-manager";
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
export interface AuthData {
user: UserWithRelations | null;
@ -201,7 +203,7 @@ export const createNewLocalUser = async (data: {
header?: string;
admin?: boolean;
}) => {
const config = getConfig();
const config = await configManager.getConfig();
const keys = await generateUserKeys();
@ -344,8 +346,6 @@ export const userToAPI = (
user: UserWithRelations,
isOwnAccount = false
): APIAccount => {
const config = getConfig();
return {
id: user.id,
username: user.username,
@ -373,7 +373,7 @@ export const userToAPI = (
header_static: "",
acct:
user.instance === null
? `${user.username}`
? user.username
: `${user.username}@${user.instance.base_url}`,
// TODO: Add these fields
limited: false,
@ -424,13 +424,13 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
username: user.username,
avatar: [
{
content: getAvatarUrl(user, getConfig()) || "",
content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`,
},
],
header: [
{
content: getHeaderUrl(user, getConfig()) || "",
content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`,
},
],
@ -458,7 +458,7 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
],
})),
public_key: {
actor: `${getConfig().http.base_url}/users/${user.id}`,
actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey,
},
extensions: {

199
index.ts
View file

@ -1,39 +1,36 @@
import { getConfig } from "~classes/configmanager";
import { jsonResponse } from "@response";
import chalk from "chalk";
import { appendFile } from "fs/promises";
import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import { mkdir } from "fs/promises";
import type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
import { initializeRedisCache } from "@redis";
import { connectMeili } from "@meilisearch";
import { matchRoute } from "~routes";
import { ConfigManager } from "config-manager";
import { client } from "~database/datasource";
import { LogLevel, LogManager, MultiLogManager } from "log-manager";
import { moduleIsEntry } from "@module";
import { createServer } from "~server";
const timeAtStart = performance.now();
console.log(`${chalk.green(`>`)} ${chalk.bold("Starting Lysand...")}`);
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
const config = getConfig();
const requests_log = Bun.file(process.cwd() + "/logs/requests.log");
const isEntry = moduleIsEntry(import.meta.url);
// If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests)
const logger = new LogManager(isEntry ? requests_log : Bun.file(`/dev/null`));
const consoleLogger = new LogManager(
isEntry ? Bun.stdout : Bun.file(`/dev/null`)
);
const dualLogger = new MultiLogManager([logger, consoleLogger]);
// Needs to be imported after config is loaded
import { client } from "~database/datasource";
await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand...");
// NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead
const isProd =
process.env.NODE_ENV === "production" || process.argv.includes("--prod");
if (!(await requests_log.exists())) {
console.log(`${chalk.green(``)} ${chalk.bold("Creating logs folder...")}`);
await mkdir(process.cwd() + "/logs");
await Bun.write(process.cwd() + "/logs/requests.log", "");
}
const redisCache = await initializeRedisCache();
if (config.meilisearch.enabled) {
await connectMeili();
await connectMeili(dualLogger);
}
if (redisCache) {
@ -46,163 +43,23 @@ try {
postCount = await client.status.count();
} catch (e) {
const error = e as PrismaClientInitializationError;
console.error(
`${chalk.red(``)} ${chalk.bold(
"Error while connecting to database: "
)} ${error.message}`
);
await logger.logError(LogLevel.CRITICAL, "Database", error);
await consoleLogger.logError(LogLevel.CRITICAL, "Database", error);
process.exit(1);
}
Bun.serve({
port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) {
/* Check for banned IPs */
const request_ip = this.requestIP(req)?.address ?? "";
const server = createServer(config, configManager, dualLogger, isProd);
for (const ip of config.http.banned_ips) {
try {
if (matches(ip, request_ip)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e;
}
}
await logRequest(req);
if (req.method === "OPTIONS") {
return jsonResponse({});
}
const { file, matchedRoute } = matchRoute(req.url);
if (matchedRoute) {
const meta = (await file).meta;
// Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", "
)}`,
});
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
} else if (
(meta.auth.requiredOnMethods ?? []).includes(req.method as any)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
}
return await (await file).default(req.clone(), matchedRoute, auth);
} else {
// Proxy response from Vite at localhost:5173 if in development mode
if (isProd) {
if (new URL(req.url).pathname.startsWith("/assets")) {
// Serve from pages/dist/assets
return new Response(
Bun.file(`./pages/dist${new URL(req.url).pathname}`)
);
}
// Serve from pages/dist
return new Response(Bun.file(`./pages/dist/index.html`));
} else {
const proxy = await fetch(
req.url.replace(
config.http.base_url,
"http://localhost:5173"
)
);
if (proxy.status !== 404) {
return proxy;
}
}
return new Response(undefined, {
status: 404,
statusText: "Route not found",
});
}
},
});
const logRequest = async (req: Request) => {
if (config.logging.log_requests_verbose) {
await appendFile(
`${process.cwd()}/logs/requests.log`,
`[${new Date().toISOString()}] ${req.method} ${
req.url
}\n\tHeaders:\n`
);
// Add headers
const headers = req.headers.entries();
for (const [key, value] of headers) {
await appendFile(
`${process.cwd()}/logs/requests.log`,
`\t\t${key}: ${value}\n`
);
}
const body = await req.clone().text();
await appendFile(
`${process.cwd()}/logs/requests.log`,
`\tBody:\n\t${body}\n`
);
} else if (config.logging.log_requests) {
await appendFile(
process.cwd() + "/logs/requests.log",
`[${new Date().toISOString()}] ${req.method} ${req.url}\n`
);
}
};
// Remove previous console.log
// console.clear();
console.log(
`${chalk.green(``)} ${chalk.bold(
`Lysand started at ${chalk.blue(
`${config.http.bind}:${config.http.bind_port}`
)} in ${chalk.gray((performance.now() - timeAtStart).toFixed(0))}ms`
)}`
await dualLogger.log(
LogLevel.INFO,
"Server",
`Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`
);
console.log(
`${chalk.green(``)} ${chalk.bold(`Database is ${chalk.blue("online")}`)}`
await dualLogger.log(
LogLevel.INFO,
"Database",
`Database is online, now serving ${postCount} posts`
);
// Print "serving x posts"
console.log(
`${chalk.green(``)} ${chalk.bold(
`Serving ${chalk.blue(postCount)} posts`
)}`
);
export { config, server };

View file

@ -153,6 +153,7 @@ export interface ConfigType {
logging: {
log_requests: boolean;
log_requests_verbose: boolean;
log_ip: boolean;
log_filters: boolean;
};
@ -351,6 +352,7 @@ export const configDefaults: ConfigType = {
logging: {
log_requests: false,
log_requests_verbose: false,
log_ip: false,
log_filters: true,
},
ratelimits: {

View file

@ -16,7 +16,7 @@ export enum LogLevel {
export class LogManager {
constructor(private output: BunFile) {
void this.write(
`--- INIT LogManager at ${new Date().toISOString()} --`
`--- INIT LogManager at ${new Date().toISOString()} ---`
);
}
@ -58,4 +58,114 @@ export class LogManager {
async logError(level: LogLevel, entity: string, error: Error) {
await this.log(level, entity, error.message);
}
/**
* Logs a request to the output
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
let string = ip ? `${ip}: ` : "";
string += `${req.method} ${req.url}`;
if (logAllDetails) {
string += `\n`;
string += ` [Headers]\n`;
// Pretty print headers
for (const [key, value] of req.headers.entries()) {
string += ` ${key}: ${value}\n`;
}
// Pretty print body
string += ` [Body]\n`;
const content_type = req.headers.get("Content-Type");
if (content_type && content_type.includes("application/json")) {
const json = await req.json();
const stringified = JSON.stringify(json, null, 4)
.split("\n")
.map(line => ` ${line}`)
.join("\n");
string += `${stringified}\n`;
} else if (
content_type &&
(content_type.includes("application/x-www-form-urlencoded") ||
content_type.includes("multipart/form-data"))
) {
const formData = await req.formData();
for (const [key, value] of formData.entries()) {
if (value.toString().length < 300) {
string += ` ${key}: ${value.toString()}\n`;
} else {
string += ` ${key}: <${value.toString().length} bytes>\n`;
}
}
} else {
const text = await req.text();
string += ` ${text}\n`;
}
}
await this.log(LogLevel.INFO, "Request", string);
}
}
/**
* Outputs to multiple LogManager instances at once
*/
export class MultiLogManager {
constructor(private logManagers: LogManager[]) {}
/**
* Logs a message to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true
) {
for (const logManager of this.logManagers) {
await logManager.log(level, entity, message, showTimestamp);
}
}
/**
* Logs an error to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
for (const logManager of this.logManagers) {
await logManager.logError(level, entity, error);
}
}
/**
* Logs a request to all logManagers
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
for (const logManager of this.logManagers) {
await logManager.logRequest(req, ip, logAllDetails);
}
}
/**
* Create a MultiLogManager from multiple LogManager instances
* @param logManagers LogManager instances to use
* @returns
*/
static fromLogManagers(...logManagers: LogManager[]) {
return new MultiLogManager(logManagers);
}
}

View file

@ -1,5 +1,5 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
import { LogManager, LogLevel } from "../index";
import { LogManager, LogLevel, MultiLogManager } from "../index";
import type fs from "fs/promises";
import {
describe,
@ -91,4 +91,141 @@ describe("LogManager", () => {
expect.stringContaining("[ERROR] TestEntity: Test error")
);
});
it("should log basic request details", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await logManager.logRequest(req, "127.0.0.1");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("127.0.0.1: GET http://localhost/test")
);
});
describe("Request logger", () => {
it("should log all request details for JSON content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ test: "value" }),
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: application/json
[Body]
{
"test": "value"
}
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog)
);
});
it("should log all request details for text content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "Test body",
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: text/plain
[Body]
Test body
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog)
);
});
it("should log all request details for FormData content-type", async () => {
const formData = new FormData();
formData.append("test", "value");
const req = new Request("http://localhost/test", {
method: "POST",
body: formData,
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: multipart/form-data; boundary=${
req.headers.get("Content-Type")?.split("boundary=")[1] ?? ""
}
[Body]
test: value
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(
expectedLog.replace("----", expect.any(String))
)
);
});
});
});
describe("MultiLogManager", () => {
let multiLogManager: MultiLogManager;
let mockLogManagers: LogManager[];
let mockLog: jest.Mock;
let mockLogError: jest.Mock;
let mockLogRequest: jest.Mock;
beforeEach(() => {
mockLog = jest.fn();
mockLogError = jest.fn();
mockLogRequest = jest.fn();
mockLogManagers = [
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
] as unknown as LogManager[];
multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers);
});
it("should log message to all logManagers", async () => {
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenCalledWith(
LogLevel.INFO,
"TestEntity",
"Test message",
true
);
});
it("should log error to all logManagers", async () => {
const error = new Error("Test error");
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockLogError).toHaveBeenCalledTimes(2);
expect(mockLogError).toHaveBeenCalledWith(
LogLevel.ERROR,
"TestEntity",
error
);
});
it("should log request to all logManagers", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await multiLogManager.logRequest(req, "127.0.0.1", true);
expect(mockLogRequest).toHaveBeenCalledTimes(2);
expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true);
});
});

147
routes.ts
View file

@ -1,5 +1,4 @@
import type { MatchedRoute } from "bun";
import type { AuthData } from "~database/entities/User";
import type { RouteHandler } from "~server/api/routes.type";
import type { APIRouteMeta } from "~types/api";
const serverPath = process.cwd() + "/server/api";
@ -8,146 +7,158 @@ const serverPath = process.cwd() + "/server/api";
// This is to allow for compilation of the routes, so that we can minify them and
// node_modules in production
export const rawRoutes = {
"/api/v1/accounts": import(serverPath + "/api/v1/accounts/index.ts"),
"/api/v1/accounts/familiar_followers": import(
"/api/v1/accounts": await import(serverPath + "/api/v1/accounts/index.ts"),
"/api/v1/accounts/familiar_followers": await import(
serverPath + "/api/v1/accounts/familiar_followers/index.ts"
),
"/api/v1/accounts/relationships": import(
"/api/v1/accounts/relationships": await import(
serverPath + "/api/v1/accounts/relationships/index.ts"
),
"/api/v1/accounts/search": import(
"/api/v1/accounts/search": await import(
serverPath + "/api/v1/accounts/search/index.ts"
),
"/api/v1/accounts/update_credentials": import(
"/api/v1/accounts/update_credentials": await import(
serverPath + "/api/v1/accounts/update_credentials/index.ts"
),
"/api/v1/accounts/verify_credentials": import(
"/api/v1/accounts/verify_credentials": await import(
serverPath + "/api/v1/accounts/verify_credentials/index.ts"
),
"/api/v1/apps": import(serverPath + "/api/v1/apps/index.ts"),
"/api/v1/apps/verify_credentials": import(
"/api/v1/apps": await import(serverPath + "/api/v1/apps/index.ts"),
"/api/v1/apps/verify_credentials": await import(
serverPath + "/api/v1/apps/verify_credentials/index.ts"
),
"/api/v1/blocks": import(serverPath + "/api/v1/blocks/index.ts"),
"/api/v1/custom_emojis": import(
"/api/v1/blocks": await import(serverPath + "/api/v1/blocks/index.ts"),
"/api/v1/custom_emojis": await import(
serverPath + "/api/v1/custom_emojis/index.ts"
),
"/api/v1/favourites": import(serverPath + "/api/v1/favourites/index.ts"),
"/api/v1/follow_requests": import(
"/api/v1/favourites": await import(
serverPath + "/api/v1/favourites/index.ts"
),
"/api/v1/follow_requests": await import(
serverPath + "/api/v1/follow_requests/index.ts"
),
"/api/v1/instance": import(serverPath + "/api/v1/instance/index.ts"),
"/api/v1/media": import(serverPath + "/api/v1/media/index.ts"),
"/api/v1/mutes": import(serverPath + "/api/v1/mutes/index.ts"),
"/api/v1/notifications": import(
"/api/v1/instance": await import(serverPath + "/api/v1/instance/index.ts"),
"/api/v1/media": await import(serverPath + "/api/v1/media/index.ts"),
"/api/v1/mutes": await import(serverPath + "/api/v1/mutes/index.ts"),
"/api/v1/notifications": await import(
serverPath + "/api/v1/notifications/index.ts"
),
"/api/v1/profile/avatar": import(serverPath + "/api/v1/profile/avatar.ts"),
"/api/v1/profile/header": import(serverPath + "/api/v1/profile/header.ts"),
"/api/v1/statuses": import(serverPath + "/api/v1/statuses/index.ts"),
"/api/v1/timelines/home": import(serverPath + "/api/v1/timelines/home.ts"),
"/api/v1/timelines/public": import(
"/api/v1/profile/avatar": await import(
serverPath + "/api/v1/profile/avatar.ts"
),
"/api/v1/profile/header": await import(
serverPath + "/api/v1/profile/header.ts"
),
"/api/v1/statuses": await import(serverPath + "/api/v1/statuses/index.ts"),
"/api/v1/timelines/home": await import(
serverPath + "/api/v1/timelines/home.ts"
),
"/api/v1/timelines/public": await import(
serverPath + "/api/v1/timelines/public.ts"
),
"/api/v2/media": import(serverPath + "/api/v2/media/index.ts"),
"/api/v2/search": import(serverPath + "/api/v2/search/index.ts"),
"/auth/login": import(serverPath + "/auth/login/index.ts"),
"/nodeinfo/2.0": import(serverPath + "/nodeinfo/2.0/index.ts"),
"/oauth/authorize-external": import(
"/api/v2/media": await import(serverPath + "/api/v2/media/index.ts"),
"/api/v2/search": await import(serverPath + "/api/v2/search/index.ts"),
"/auth/login": await import(serverPath + "/auth/login/index.ts"),
"/nodeinfo/2.0": await import(serverPath + "/nodeinfo/2.0/index.ts"),
"/oauth/authorize-external": await import(
serverPath + "/oauth/authorize-external/index.ts"
),
"/oauth/providers": import(serverPath + "/oauth/providers/index.ts"),
"/oauth/token": import(serverPath + "/oauth/token/index.ts"),
"/api/v1/accounts/[id]": import(
"/oauth/providers": await import(serverPath + "/oauth/providers/index.ts"),
"/oauth/token": await import(serverPath + "/oauth/token/index.ts"),
"/api/v1/accounts/[id]": await import(
serverPath + "/api/v1/accounts/[id]/index.ts"
),
"/api/v1/accounts/[id]/block": import(
"/api/v1/accounts/[id]/block": await import(
serverPath + "/api/v1/accounts/[id]/block.ts"
),
"/api/v1/accounts/[id]/follow": import(
"/api/v1/accounts/[id]/follow": await import(
serverPath + "/api/v1/accounts/[id]/follow.ts"
),
"/api/v1/accounts/[id]/followers": import(
"/api/v1/accounts/[id]/followers": await import(
serverPath + "/api/v1/accounts/[id]/followers.ts"
),
"/api/v1/accounts/[id]/following": import(
"/api/v1/accounts/[id]/following": await import(
serverPath + "/api/v1/accounts/[id]/following.ts"
),
"/api/v1/accounts/[id]/mute": import(
"/api/v1/accounts/[id]/mute": await import(
serverPath + "/api/v1/accounts/[id]/mute.ts"
),
"/api/v1/accounts/[id]/note": import(
"/api/v1/accounts/[id]/note": await import(
serverPath + "/api/v1/accounts/[id]/note.ts"
),
"/api/v1/accounts/[id]/pin": import(
"/api/v1/accounts/[id]/pin": await import(
serverPath + "/api/v1/accounts/[id]/pin.ts"
),
"/api/v1/accounts/[id]/remove_from_followers": import(
"/api/v1/accounts/[id]/remove_from_followers": await import(
serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts"
),
"/api/v1/accounts/[id]/statuses": import(
"/api/v1/accounts/[id]/statuses": await import(
serverPath + "/api/v1/accounts/[id]/statuses.ts"
),
"/api/v1/accounts/[id]/unblock": import(
"/api/v1/accounts/[id]/unblock": await import(
serverPath + "/api/v1/accounts/[id]/unblock.ts"
),
"/api/v1/accounts/[id]/unfollow": import(
"/api/v1/accounts/[id]/unfollow": await import(
serverPath + "/api/v1/accounts/[id]/unfollow.ts"
),
"/api/v1/accounts/[id]/unmute": import(
"/api/v1/accounts/[id]/unmute": await import(
serverPath + "/api/v1/accounts/[id]/unmute.ts"
),
"/api/v1/accounts/[id]/unpin": import(
"/api/v1/accounts/[id]/unpin": await import(
serverPath + "/api/v1/accounts/[id]/unpin.ts"
),
"/api/v1/follow_requests/[account_id]/authorize": import(
"/api/v1/follow_requests/[account_id]/authorize": await import(
serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts"
),
"/api/v1/follow_requests/[account_id]/reject": import(
"/api/v1/follow_requests/[account_id]/reject": await import(
serverPath + "/api/v1/follow_requests/[account_id]/reject.ts"
),
"/api/v1/media/[id]": import(serverPath + "/api/v1/media/[id]/index.ts"),
"/api/v1/statuses/[id]": import(
"/api/v1/media/[id]": await import(
serverPath + "/api/v1/media/[id]/index.ts"
),
"/api/v1/statuses/[id]": await import(
serverPath + "/api/v1/statuses/[id]/index.ts"
),
"/api/v1/statuses/[id]/context": import(
"/api/v1/statuses/[id]/context": await import(
serverPath + "/api/v1/statuses/[id]/context.ts"
),
"/api/v1/statuses/[id]/favourite": import(
"/api/v1/statuses/[id]/favourite": await import(
serverPath + "/api/v1/statuses/[id]/favourite.ts"
),
"/api/v1/statuses/[id]/favourited_by": import(
"/api/v1/statuses/[id]/favourited_by": await import(
serverPath + "/api/v1/statuses/[id]/favourited_by.ts"
),
"/api/v1/statuses/[id]/pin": import(
"/api/v1/statuses/[id]/pin": await import(
serverPath + "/api/v1/statuses/[id]/pin.ts"
),
"/api/v1/statuses/[id]/reblog": import(
"/api/v1/statuses/[id]/reblog": await import(
serverPath + "/api/v1/statuses/[id]/reblog.ts"
),
"/api/v1/statuses/[id]/reblogged_by": import(
"/api/v1/statuses/[id]/reblogged_by": await import(
serverPath + "/api/v1/statuses/[id]/reblogged_by.ts"
),
"/api/v1/statuses/[id]/source": import(
"/api/v1/statuses/[id]/source": await import(
serverPath + "/api/v1/statuses/[id]/source.ts"
),
"/api/v1/statuses/[id]/unfavourite": import(
"/api/v1/statuses/[id]/unfavourite": await import(
serverPath + "/api/v1/statuses/[id]/unfavourite.ts"
),
"/api/v1/statuses/[id]/unpin": import(
"/api/v1/statuses/[id]/unpin": await import(
serverPath + "/api/v1/statuses/[id]/unpin.ts"
),
"/api/v1/statuses/[id]/unreblog": import(
"/api/v1/statuses/[id]/unreblog": await import(
serverPath + "/api/v1/statuses/[id]/unreblog.ts"
),
"/media/[id]": import(serverPath + "/media/[id]/index.ts"),
"/oauth/callback/[issuer]": import(
"/media/[id]": await import(serverPath + "/media/[id]/index.ts"),
"/oauth/callback/[issuer]": await import(
serverPath + "/oauth/callback/[issuer]/index.ts"
),
"/object/[uuid]": import(serverPath + "/object/[uuid]/index.ts"),
"/users/[uuid]": import(serverPath + "/users/[uuid]/index.ts"),
"/users/[uuid]/inbox": import(serverPath + "/users/[uuid]/inbox/index.ts"),
"/users/[uuid]/outbox": import(
"/object/[uuid]": await import(serverPath + "/object/[uuid]/index.ts"),
"/users/[uuid]": await import(serverPath + "/users/[uuid]/index.ts"),
"/users/[uuid]/inbox": await import(
serverPath + "/users/[uuid]/inbox/index.ts"
),
"/users/[uuid]/outbox": await import(
serverPath + "/users/[uuid]/outbox/index.ts"
),
};
@ -158,7 +169,7 @@ export const routeMatcher = new Bun.FileSystemRouter({
dir: process.cwd() + "/server/api",
});
export const matchRoute = (url: string) => {
export const matchRoute = <T = Record<string, never>>(url: string) => {
const route = routeMatcher.match(url);
if (!route) return { file: null, matchedRoute: null };
@ -166,11 +177,7 @@ export const matchRoute = (url: string) => {
// @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file
file: rawRoutes[route.name] as Promise<{
meta: APIRouteMeta;
default: (
req: Request,
matchedRoute: MatchedRoute,
auth: AuthData
) => Response | Promise<Response>;
default: RouteHandler<T>;
}>,
matchedRoute: route,
};

157
server.ts Normal file
View file

@ -0,0 +1,157 @@
import { jsonResponse } from "@response";
import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import type { ConfigManager, ConfigType } from "config-manager";
import type { LogManager, MultiLogManager } from "log-manager";
import { LogLevel } from "log-manager";
import { RequestParser } from "request-parser";
export const createServer = (
config: ConfigType,
configManager: ConfigManager,
logger: LogManager | MultiLogManager,
isProd: boolean
) =>
Bun.serve({
port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) {
// Check for banned IPs
const request_ip = this.requestIP(req)?.address ?? "";
for (const ip of config.http.banned_ips) {
try {
if (matches(ip, request_ip)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e;
}
}
// Check for banned user agents (regex)
const ua = req.headers.get("User-Agent") ?? "";
for (const agent of config.http.banned_user_agents) {
if (new RegExp(agent).test(ua)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
}
if (config.logging.log_requests) {
await logger.logRequest(
req,
config.logging.log_ip ? request_ip : undefined,
config.logging.log_requests_verbose
);
}
if (req.method === "OPTIONS") {
return jsonResponse({});
}
// If it isn't dynamically imported, it causes trouble with imports
// There shouldn't be a performance hit after bundling right?
const { matchRoute } = await import("~routes");
const { file, matchedRoute } = matchRoute(req.url);
if (matchedRoute) {
const meta = (await file).meta;
// Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", "
)}`,
});
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
} else if (
(meta.auth.requiredOnMethods ?? []).includes(
req.method as any
)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
}
let parsedRequest = {};
try {
parsedRequest = await new RequestParser(req).toObject();
} catch (e) {
await logger.logError(
LogLevel.ERROR,
"Server.RouteRequestParser",
e as Error
);
return new Response(undefined, {
status: 400,
statusText: "Bad request",
});
}
return await (
await file
).default(req.clone(), matchedRoute, {
auth,
configManager,
parsedRequest,
});
} else {
// Proxy response from Vite at localhost:5173 if in development mode
if (isProd) {
if (new URL(req.url).pathname.startsWith("/assets")) {
// Serve from pages/dist/assets
return new Response(
Bun.file(`./pages/dist${new URL(req.url).pathname}`)
);
}
// Serve from pages/dist
return new Response(Bun.file(`./pages/dist/index.html`));
} else {
const proxy = await fetch(
req.url.replace(
config.http.base_url,
"http://localhost:5173"
)
);
if (proxy.status !== 404) {
return proxy;
}
}
return new Response(undefined, {
status: 404,
statusText: "Route not found",
});
}
},
});

View file

@ -19,13 +19,13 @@ export const meta = applyConfig({
* Find familiar followers (followers of a user that you also follow)
*/
export default apiRoute<{
"id[]": string[];
id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = extraData.parsedRequest;
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {

View file

@ -22,13 +22,13 @@ export const meta = applyConfig({
* Find relationships
*/
export default apiRoute<{
"id[]": string[];
id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = extraData.parsedRequest;
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {

View file

@ -32,8 +32,8 @@ export default apiRoute<{
sensitive?: boolean;
language?: string;
content_type?: string;
"media_ids[]"?: string[];
"poll[options][]"?: string[];
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
@ -88,8 +88,8 @@ export default apiRoute<{
status: statusText,
content_type,
"poll[expires_in]": expires_in,
"poll[options][]": options,
"media_ids[]": media_ids,
"poll[options]": options,
media_ids: media_ids,
spoiler_text,
sensitive,
} = extraData.parsedRequest;

View file

@ -1 +0,0 @@
// Empty file

View file

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "~classes/configmanager";
import type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ConfigManager } from "config-manager";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
import {
@ -11,8 +9,10 @@ import {
} from "~database/entities/User";
import type { APIEmoji } from "~types/entities/emoji";
import type { APIInstance } from "~types/entities/instance";
import { sendTestRequest, wrapRelativeUrl } from "./utils";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const base_url = config.http.base_url;
let token: Token;
let user: UserWithRelations;
@ -71,14 +71,16 @@ describe("API Tests", () => {
describe("GET /api/v1/instance", () => {
test("should return an APIInstance object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/instance`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -117,15 +119,21 @@ describe("API Tests", () => {
},
});
});
test("should return an array of at least one custom emoji", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/custom_emojis`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/custom_emojis`,
base_url
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
)
);
expect(response.status).toBe(200);
@ -139,6 +147,7 @@ describe("API Tests", () => {
expect(emojis[0].shortcode).toBeString();
expect(emojis[0].url).toBeString();
});
afterAll(async () => {
await client.emoji.deleteMany({
where: {

View file

@ -1,6 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "~classes/configmanager";
import type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { client } from "~database/datasource";
@ -12,21 +9,24 @@ import {
import type { APIAccount } from "~types/entities/account";
import type { APIRelationship } from "~types/entities/relationship";
import type { APIStatus } from "~types/entities/status";
import { ConfigManager } from "config-manager";
import { sendTestRequest, wrapRelativeUrl } from "~tests/utils";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const base_url = config.http.base_url;
let token: Token;
let user: UserWithRelations;
let user2: UserWithRelations;
beforeAll(async () => {
/* await client.user.deleteMany({
await client.user.deleteMany({
where: {
username: {
in: ["test", "test2"],
},
},
}); */
});
user = await createNewLocalUser({
email: "test@test.com",
@ -87,8 +87,9 @@ afterAll(async () => {
describe("API Tests", () => {
describe("POST /api/v1/accounts/:id", () => {
test("should return a 404 error when trying to fetch a non-existent user", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/999999`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
{
method: "GET",
headers: {
@ -96,6 +97,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(404);
@ -107,8 +109,12 @@ describe("API Tests", () => {
describe("PATCH /api/v1/accounts/update_credentials", () => {
test("should update the authenticated user's display name", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/update_credentials`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
"/api/v1/accounts/update_credentials",
base_url
),
{
method: "PATCH",
headers: {
@ -119,6 +125,7 @@ describe("API Tests", () => {
display_name: "New Display Name",
}),
}
)
);
expect(response.status).toBe(200);
@ -134,8 +141,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/verify_credentials", () => {
test("should return the authenticated user's account information", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/verify_credentials`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
"/api/v1/accounts/verify_credentials",
base_url
),
{
method: "GET",
headers: {
@ -143,6 +154,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -179,8 +191,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/:id/statuses", () => {
test("should return the statuses of the specified user", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user.id}/statuses`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user.id}/statuses`,
base_url
),
{
method: "GET",
headers: {
@ -188,6 +204,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -203,8 +220,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/follow", () => {
test("should follow the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/follow`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/follow`,
base_url
),
{
method: "POST",
headers: {
@ -213,6 +234,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -229,8 +251,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unfollow", () => {
test("should unfollow the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unfollow`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unfollow`,
base_url
),
{
method: "POST",
headers: {
@ -239,6 +265,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -255,8 +282,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/remove_from_followers", () => {
test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/remove_from_followers`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/remove_from_followers`,
base_url
),
{
method: "POST",
headers: {
@ -265,6 +296,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -281,8 +313,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/block", () => {
test("should block the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/block`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/block`,
base_url
),
{
method: "POST",
headers: {
@ -291,6 +327,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -307,14 +344,13 @@ describe("API Tests", () => {
describe("GET /api/v1/blocks", () => {
test("should return an array of APIAccount objects for the user's blocked accounts", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/blocks`,
{
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/api/v1/blocks", base_url), {
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
})
);
expect(response.status).toBe(200);
@ -331,8 +367,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unblock", () => {
test("should unblock the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unblock`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unblock`,
base_url
),
{
method: "POST",
headers: {
@ -341,6 +381,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -357,8 +398,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => {
test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/mute`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/mute`,
base_url
),
{
method: "POST",
headers: {
@ -367,6 +412,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({ notifications: true }),
}
)
);
expect(response.status).toBe(200);
@ -382,8 +428,12 @@ describe("API Tests", () => {
});
test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/mute`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/mute`,
base_url
),
{
method: "POST",
headers: {
@ -392,6 +442,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({ notifications: false }),
}
)
);
expect(response.status).toBe(200);
@ -409,14 +460,13 @@ describe("API Tests", () => {
describe("GET /api/v1/mutes", () => {
test("should return an array of APIAccount objects for the user's muted accounts", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/mutes`,
{
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/api/v1/mutes", base_url), {
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
})
);
expect(response.status).toBe(200);
@ -434,8 +484,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unmute", () => {
test("should unmute the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unmute`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unmute`,
base_url
),
{
method: "POST",
headers: {
@ -444,6 +498,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -460,8 +515,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/pin", () => {
test("should pin the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/pin`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/pin`,
base_url
),
{
method: "POST",
headers: {
@ -470,6 +529,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -486,8 +546,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unpin", () => {
test("should unpin the specified user and return an APIRelationship object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unpin`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unpin`,
base_url
),
{
method: "POST",
headers: {
@ -496,6 +560,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -512,8 +577,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/note", () => {
test("should update the specified account's note and return the updated account object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/note`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/note`,
base_url
),
{
method: "POST",
headers: {
@ -522,6 +591,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({ comment: "This is a new note" }),
}
)
);
expect(response.status).toBe(200);
@ -538,14 +608,19 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/relationships", () => {
test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/relationships?id[]=${user2.id}`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/relationships?id[]=${user2.id}`,
base_url
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
)
);
expect(response.status).toBe(200);
@ -571,8 +646,9 @@ describe("API Tests", () => {
describe("DELETE /api/v1/profile/avatar", () => {
test("should delete the avatar of the authenticated user and return the updated account object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/profile/avatar`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl("/api/v1/profile/avatar", base_url),
{
method: "DELETE",
headers: {
@ -580,6 +656,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -596,8 +673,9 @@ describe("API Tests", () => {
describe("DELETE /api/v1/profile/header", () => {
test("should delete the header of the authenticated user and return the updated account object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/profile/header`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl("/api/v1/profile/header", base_url),
{
method: "DELETE",
headers: {
@ -605,6 +683,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -621,8 +700,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/familiar_followers", () => {
test("should follow the user", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user2.id}/follow`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/follow`,
base_url
),
{
method: "POST",
headers: {
@ -631,6 +714,7 @@ describe("API Tests", () => {
},
body: JSON.stringify({}),
}
)
);
expect(response.status).toBe(200);
@ -640,14 +724,19 @@ describe("API Tests", () => {
});
test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/familiar_followers?id[]=${user2.id}`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/familiar_followers?id[]=${user2.id}`,
base_url
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
)
);
expect(response.status).toBe(200);

View file

@ -1,6 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "~classes/configmanager";
import type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { client } from "~database/datasource";
@ -13,8 +10,11 @@ import type { APIAccount } from "~types/entities/account";
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIContext } from "~types/entities/context";
import type { APIStatus } from "~types/entities/status";
import { ConfigManager } from "config-manager";
import { sendTestRequest, wrapRelativeUrl } from "~tests/utils";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const base_url = config.http.base_url;
let token: Token;
let user: UserWithRelations;
@ -86,8 +86,9 @@ describe("API Tests", () => {
const formData = new FormData();
formData.append("file", new Blob(["test"], { type: "text/plain" }));
const response = await fetch(
`${config.http.base_url}/api/v2/media`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(`${base_url}/api/v2/media`, base_url),
{
method: "POST",
headers: {
@ -95,6 +96,7 @@ describe("API Tests", () => {
},
body: formData,
}
)
);
expect(response.status).toBe(202);
@ -112,8 +114,9 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses", () => {
test("should create a new status and return an APIStatus object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
{
method: "POST",
headers: {
@ -126,6 +129,7 @@ describe("API Tests", () => {
media_ids: [media1?.id],
}),
}
)
);
expect(response.status).toBe(200);
@ -158,8 +162,9 @@ describe("API Tests", () => {
});
test("should create a new status in reply to the previous one", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
{
method: "POST",
headers: {
@ -172,6 +177,7 @@ describe("API Tests", () => {
in_reply_to_id: status?.id,
}),
}
)
);
expect(response.status).toBe(200);
@ -206,14 +212,20 @@ describe("API Tests", () => {
describe("GET /api/v1/statuses/:id", () => {
test("should return the specified status object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}`,
base_url
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -251,8 +263,12 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/reblog", () => {
test("should reblog the specified status and return the reblogged status object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}/reblog`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/reblog`,
base_url
),
{
method: "POST",
headers: {
@ -260,6 +276,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -277,8 +294,12 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/unreblog", () => {
test("should unreblog the specified status and return the original status object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}/unreblog`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/unreblog`,
base_url
),
{
method: "POST",
headers: {
@ -286,6 +307,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -302,8 +324,12 @@ describe("API Tests", () => {
describe("GET /api/v1/statuses/:id/context", () => {
test("should return the context of the specified status", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}/context`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/context`,
base_url
),
{
method: "GET",
headers: {
@ -311,6 +337,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -330,14 +357,20 @@ describe("API Tests", () => {
describe("GET /api/v1/timelines/public", () => {
test("should return an array of APIStatus objects that includes the created status", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/timelines/public`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/timelines/public`,
base_url
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -353,8 +386,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/:id/statuses", () => {
test("should return the statuses of the specified user", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/accounts/${user.id}/statuses`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/accounts/${user.id}/statuses`,
base_url
),
{
method: "GET",
headers: {
@ -362,6 +399,7 @@ describe("API Tests", () => {
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -384,14 +422,20 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/favourite", () => {
test("should favourite the specified status object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}/favourite`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/favourite`,
base_url
),
{
method: "POST",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -400,14 +444,20 @@ describe("API Tests", () => {
describe("GET /api/v1/statuses/:id/favourited_by", () => {
test("should return an array of User objects who favourited the specified status", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}/favourited_by`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/favourited_by`,
base_url
),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -425,14 +475,20 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/unfavourite", () => {
test("should unfavourite the specified status object", async () => {
// Unfavourite the status
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}/unfavourite`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/unfavourite`,
base_url
),
{
method: "POST",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);
@ -449,14 +505,19 @@ describe("API Tests", () => {
describe("DELETE /api/v1/statuses/:id", () => {
test("should delete the specified status object", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/statuses/${status?.id}`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}`,
base_url
),
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
)
);
expect(response.status).toBe(200);

View file

@ -1,24 +0,0 @@
/* import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { Instance } from "~database/entities/Instance";
let instance: Instance;
beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
});
describe("Instance", () => {
it("should add an instance to the database if it doesn't already exist", async () => {
const url = "https://mastodon.social";
instance = await Instance.addIfNotExists(url);
expect(instance.base_url).toBe("mastodon.social");
});
});
afterAll(async () => {
await instance.remove();
await AppDataSource.destroy();
});
*/

View file

@ -1,99 +0,0 @@
import { type ConfigType, getConfig } from "~classes/configmanager";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { LocalBackend, S3Backend } from "~classes/media";
import { unlink } from "fs/promises";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
const originalConfig = getConfig();
const modifiedConfig: ConfigType = {
...originalConfig,
media: {
...originalConfig.media,
conversion: {
...originalConfig.media.conversion,
convert_images: false,
},
},
};
describe("LocalBackend", () => {
let localBackend: LocalBackend;
let fileName: string;
beforeAll(() => {
localBackend = new LocalBackend(modifiedConfig);
});
afterAll(async () => {
await unlink(`${process.cwd()}/uploads/${fileName}`);
});
describe("addMedia", () => {
it("should write the file to the local filesystem and return the hash", async () => {
const media = new File(["test"], "test.txt", {
type: "text/plain",
});
const hash = await localBackend.addMedia(media);
fileName = hash;
expect(hash).toBeDefined();
});
});
describe("getMediaByHash", () => {
it("should retrieve the file from the local filesystem and return it as a File object", async () => {
const media = await localBackend.getMediaByHash(fileName);
expect(media).toBeInstanceOf(File);
});
it("should return null if the file does not exist", async () => {
const media =
await localBackend.getMediaByHash("does-not-exist.txt");
expect(media).toBeNull();
});
});
});
describe("S3Backend", () => {
const s3Backend = new S3Backend(modifiedConfig);
let fileName: string;
afterAll(async () => {
const command = new DeleteObjectCommand({
Bucket: modifiedConfig.s3.bucket_name,
Key: fileName,
});
await s3Backend.client.send(command);
});
describe("addMedia", () => {
it("should write the file to the S3 bucket and return the hash", async () => {
const media = new File(["test"], "test.txt", {
type: "text/plain",
});
const hash = await s3Backend.addMedia(media);
fileName = hash;
expect(hash).toBeDefined();
});
});
describe("getMediaByHash", () => {
it("should retrieve the file from the S3 bucket and return it as a File object", async () => {
const media = await s3Backend.getMediaByHash(fileName);
expect(media).toBeInstanceOf(File);
});
it("should return null if the file does not exist", async () => {
const media = await s3Backend.getMediaByHash("does-not-exist.txt");
expect(media).toBeNull();
});
});
});

View file

@ -1 +0,0 @@
// Empty file

View file

@ -1,10 +1,11 @@
import { getConfig } from "~classes/configmanager";
import type { Application, Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
import { sendTestRequest, wrapRelativeUrl } from "./utils";
const config = getConfig();
// const config = await new ConfigManager({}).getConfig();
const base_url = "http://lysand.localhost:8080"; //config.http.base_url;
let client_id: string;
let client_secret: string;
@ -30,10 +31,12 @@ describe("POST /api/v1/apps/", () => {
formData.append("redirect_uris", "https://example.com");
formData.append("scopes", "read write");
const response = await fetch(`${config.http.base_url}/api/v1/apps/`, {
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/api/v1/apps/", base_url), {
method: "POST",
body: formData,
});
})
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
@ -65,14 +68,19 @@ describe("POST /auth/login/", () => {
formData.append("email", "test@test.com");
formData.append("password", "test");
const response = await fetch(
`${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
base_url
),
{
method: "POST",
body: formData,
redirect: "manual",
}
)
);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toMatch(
/https:\/\/example.com\?code=/
@ -94,11 +102,12 @@ describe("POST /oauth/token/", () => {
formData.append("client_secret", client_secret);
formData.append("scope", "read+write");
const response = await fetch(`${config.http.base_url}/oauth/token/`, {
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/oauth/token/", base_url), {
method: "POST",
// Do not set the Content-Type header for some reason
body: formData,
});
})
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = await response.json();
@ -119,15 +128,15 @@ describe("POST /oauth/token/", () => {
describe("GET /api/v1/apps/verify_credentials", () => {
test("should return the authenticated application's credentials", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/apps/verify_credentials`,
const response = await sendTestRequest(
new Request(
wrapRelativeUrl("/api/v1/apps/verify_credentials", base_url),
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
}
)
);
expect(response.status).toBe(200);

15
tests/utils.ts Normal file
View file

@ -0,0 +1,15 @@
import { server } from "~index";
/**
* This allows us to send a test request to the server even when it isnt running
* CURRENTLY NOT WORKING, NEEDS TO BE FIXED
* @param req Request to send
* @returns Response from the server
*/
export async function sendTestRequest(req: Request) {
return server.fetch(req);
}
export function wrapRelativeUrl(url: string, base_url: string) {
return new URL(url, base_url);
}

View file

@ -14,7 +14,7 @@ export const convertTextToHtml = async (
content_type?: string
) => {
if (content_type === "text/markdown") {
return linkifyHtml(await sanitizeHtml(parse(text)));
return linkifyHtml(await sanitizeHtml(await parse(text)));
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Implement MFM

View file

@ -1,17 +1,18 @@
import { getConfig } from "~classes/configmanager";
import chalk from "chalk";
import { client } from "~database/datasource";
import { Meilisearch } from "meilisearch";
import type { Status, User } from "@prisma/client";
import { ConfigManager } from "config-manager";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
export const meilisearch = new Meilisearch({
host: `${config.meilisearch.host}:${config.meilisearch.port}`,
apiKey: config.meilisearch.api_key,
});
export const connectMeili = async () => {
export const connectMeili = async (logger: MultiLogManager | LogManager) => {
if (!config.meilisearch.enabled) return;
if (await meilisearch.isHealthy()) {
@ -31,14 +32,16 @@ export const connectMeili = async () => {
.index(MeiliIndexType.Statuses)
.updateSearchableAttributes(["content"]);
console.log(
`${chalk.green(``)} ${chalk.bold(`Connected to Meilisearch`)}`
await logger.log(
LogLevel.INFO,
"Meilisearch",
"Connected to Meilisearch"
);
} else {
console.error(
`${chalk.red(``)} ${chalk.bold(
`Error while connecting to Meilisearch`
)}`
await logger.log(
LogLevel.CRITICAL,
"Meilisearch",
"Error while connecting to Meilisearch"
);
process.exit(1);
}

31
utils/module.ts Normal file
View file

@ -0,0 +1,31 @@
import { fileURLToPath } from "url";
/**
* Determines whether a module is the entry point for the running node process.
* This works for both CommonJS and ES6 environments.
*
* ### CommonJS
* ```js
* if (moduleIsEntry(module)) {
* console.log('WOO HOO!!!');
* }
* ```
*
* ### ES6
* ```js
* if (moduleIsEntry(import.meta.url)) {
* console.log('WOO HOO!!!');
* }
* ```
*/
export const moduleIsEntry = (moduleOrImportMetaUrl: NodeModule | string) => {
if (typeof moduleOrImportMetaUrl === "string") {
return process.argv[1] === fileURLToPath(moduleOrImportMetaUrl);
}
if (typeof require !== "undefined" && "exports" in moduleOrImportMetaUrl) {
return require.main === moduleOrImportMetaUrl;
}
return false;
};

View file

@ -1,10 +1,10 @@
import { getConfig } from "~classes/configmanager";
import type { Prisma } from "@prisma/client";
import chalk from "chalk";
import { ConfigManager } from "config-manager";
import Redis from "ioredis";
import { createPrismaRedisCache } from "prisma-redis-middleware";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const cacheRedis = config.redis.cache.enabled
? new Redis({

View file

@ -1,96 +0,0 @@
/**
* Takes a request, and turns FormData or query parameters
* into a JSON object as would be returned by req.json()
* This is a translation layer that allows clients to use
* either FormData, query parameters, or JSON in the request
* @param request The request to parse
*/
/* export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
const query = new URL(request.url).searchParams;
let output: Partial<T> = {};
// Parse SearchParams arrays into JSON arrays
const arrayKeys = [...query.keys()].filter(key => key.endsWith("[]"));
const nonArrayKeys = [...query.keys()].filter(key => !key.endsWith("[]"));
for (const key of arrayKeys) {
const value = query.getAll(key);
query.delete(key);
query.append(key, JSON.stringify(value));
}
// Append non array keys to output
for (const key of nonArrayKeys) {
// @ts-expect-error Complains about type
output[key] = query.get(key);
}
const queryEntries = [...query.entries()];
if (queryEntries.length > 0) {
const data: Record<string, string | string[]> = {};
const arrayKeys = [...query.keys()].filter(key => key.endsWith("[]"));
for (const key of arrayKeys) {
const value = query.getAll(key);
query.delete(key);
// @ts-expect-error JSON arrays are valid
data[key] = JSON.parse(value);
}
output = {
...output,
...(data as T),
};
}
// if request contains a JSON body
if (request.headers.get("Content-Type")?.includes("application/json")) {
try {
output = {
...output,
...((await request.json()) as T),
};
} catch {
// Invalid JSON
}
}
// If request contains FormData
if (request.headers.get("Content-Type")?.includes("multipart/form-data")) {
// @ts-expect-error It hates entries() for some reason
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const formData = [...(await request.formData()).entries()];
if (formData.length > 0) {
const data: Record<string, string | File> = {};
for (const [key, value] of formData) {
// If object, parse as JSON
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-base-to-string, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
data[key] = JSON.parse(value.toString());
} catch {
// If a file, set as a file
if (value instanceof File) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
data[key] = value;
} else {
// Otherwise, set as a string
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
data[key] = value.toString();
}
}
}
output = {
...output,
...(data as T),
};
}
}
return output;
}
*/

View file

@ -1,8 +1,8 @@
import { getConfig } from "~classes/configmanager";
import { ConfigManager } from "config-manager";
import { sanitize } from "isomorphic-dompurify";
export const sanitizeHtml = async (html: string) => {
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const sanitizedHtml = sanitize(html, {
ALLOWED_TAGS: [