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_requests = true
# Log request and their contents (warning: this is a lot of data) # Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false log_requests_verbose = false
# For GDPR compliance, you can disable logging of IPs
log_ip = false
# Log all filtered objects # Log all filtered objects
log_filters = true log_filters = true

View file

@ -1,8 +1,8 @@
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { getConfig } from "../utils/config";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
const client = new PrismaClient({ const client = new PrismaClient({
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`, 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 */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Like as LysandLike } from "~types/lysand/Object"; import type { Like as LysandLike } from "~types/lysand/Object";
import { getConfig } from "~classes/configmanager";
import type { Like } from "@prisma/client"; import type { Like } from "@prisma/client";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { UserWithRelations } from "./User"; import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status"; import type { StatusWithRelations } from "./Status";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
/** /**
* Represents a Like entity in the database. * Represents a Like entity in the database.
@ -16,7 +18,7 @@ export const toLysand = (like: Like): LysandLike => {
type: "Like", type: "Like",
created_at: new Date(like.createdAt).toISOString(), created_at: new Date(like.createdAt).toISOString(),
object: (like as any).liked?.uri, 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 { Worker } from "bullmq";
import { client, federationQueue } from "~database/datasource"; import { client, federationQueue } from "~database/datasource";
import { import {
@ -7,8 +6,9 @@ import {
type StatusWithRelations, type StatusWithRelations,
} from "./Status"; } from "./Status";
import type { User } from "@prisma/client"; 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( export const federationWorker = new Worker(
"federation", "federation",

View file

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { getConfig } from "~classes/configmanager";
import type { UserWithRelations } from "./User"; import type { UserWithRelations } from "./User";
import { import {
fetchRemoteUser, fetchRemoteUser,
@ -29,8 +28,9 @@ import { parse } from "marked";
import linkifyStr from "linkify-string"; import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html"; import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch"; import { addStausToMeilisearch } from "@meilisearch";
import { ConfigManager } from "config-manager";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
export const statusAndUserRelations: Prisma.StatusInclude = { export const statusAndUserRelations: Prisma.StatusInclude = {
author: { author: {
@ -349,7 +349,9 @@ export const createNewStatus = async (data: {
// Get HTML version of content // Get HTML version of content
if (data.content_type === "text/markdown") { 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") { } else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM // Parse as MFM
} else { } else {
@ -480,7 +482,9 @@ export const editStatus = async (
// Get HTML version of content // Get HTML version of content
if (data.content_type === "text/markdown") { 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") { } else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM // Parse as MFM
} else { } else {
@ -612,9 +616,9 @@ export const statusToAPI = async (
}; };
}; };
export const statusToActivityPub = async ( /* export const statusToActivityPub = async (
status: StatusWithRelations, status: StatusWithRelations
user?: UserWithRelations // user?: UserWithRelations
): Promise<any> => { ): Promise<any> => {
// replace any with your ActivityPub type // replace any with your ActivityPub type
return { return {
@ -657,7 +661,7 @@ export const statusToActivityPub = async (
visibility: "public", // adjust as needed visibility: "public", // adjust as needed
// add more fields as needed // add more fields as needed
}; };
}; }; */
export const statusToLysand = (status: StatusWithRelations): Note => { export const statusToLysand = (status: StatusWithRelations): Note => {
return { 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 { APIAccount } from "~types/entities/account";
import type { User as LysandUser } from "~types/lysand/Object"; import type { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
@ -10,6 +8,10 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance"; import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source"; import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch"; import { addUserToMeilisearch } from "@meilisearch";
import { ConfigManager, type ConfigType } from "config-manager";
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
export interface AuthData { export interface AuthData {
user: UserWithRelations | null; user: UserWithRelations | null;
@ -201,7 +203,7 @@ export const createNewLocalUser = async (data: {
header?: string; header?: string;
admin?: boolean; admin?: boolean;
}) => { }) => {
const config = getConfig(); const config = await configManager.getConfig();
const keys = await generateUserKeys(); const keys = await generateUserKeys();
@ -344,8 +346,6 @@ export const userToAPI = (
user: UserWithRelations, user: UserWithRelations,
isOwnAccount = false isOwnAccount = false
): APIAccount => { ): APIAccount => {
const config = getConfig();
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
@ -373,7 +373,7 @@ export const userToAPI = (
header_static: "", header_static: "",
acct: acct:
user.instance === null user.instance === null
? `${user.username}` ? user.username
: `${user.username}@${user.instance.base_url}`, : `${user.username}@${user.instance.base_url}`,
// TODO: Add these fields // TODO: Add these fields
limited: false, limited: false,
@ -424,13 +424,13 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
username: user.username, username: user.username,
avatar: [ avatar: [
{ {
content: getAvatarUrl(user, getConfig()) || "", content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`, content_type: `image/${user.avatar.split(".")[1]}`,
}, },
], ],
header: [ header: [
{ {
content: getHeaderUrl(user, getConfig()) || "", content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`, content_type: `image/${user.header.split(".")[1]}`,
}, },
], ],
@ -458,7 +458,7 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
], ],
})), })),
public_key: { public_key: {
actor: `${getConfig().http.base_url}/users/${user.id}`, actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey, public_key: user.publicKey,
}, },
extensions: { 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 type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
import { initializeRedisCache } from "@redis"; import { initializeRedisCache } from "@redis";
import { connectMeili } from "@meilisearch"; 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(); 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 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 await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand...");
import { client } from "~database/datasource";
// NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead // NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead
const isProd = const isProd =
process.env.NODE_ENV === "production" || process.argv.includes("--prod"); 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(); const redisCache = await initializeRedisCache();
if (config.meilisearch.enabled) { if (config.meilisearch.enabled) {
await connectMeili(); await connectMeili(dualLogger);
} }
if (redisCache) { if (redisCache) {
@ -46,163 +43,23 @@ try {
postCount = await client.status.count(); postCount = await client.status.count();
} catch (e) { } catch (e) {
const error = e as PrismaClientInitializationError; const error = e as PrismaClientInitializationError;
console.error( await logger.logError(LogLevel.CRITICAL, "Database", error);
`${chalk.red(``)} ${chalk.bold( await consoleLogger.logError(LogLevel.CRITICAL, "Database", error);
"Error while connecting to database: "
)} ${error.message}`
);
process.exit(1); process.exit(1);
} }
Bun.serve({ const server = createServer(config, configManager, dualLogger, isProd);
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) { await dualLogger.log(
try { LogLevel.INFO,
if (matches(ip, request_ip)) { "Server",
return new Response(undefined, { `Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`
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) { await dualLogger.log(
return proxy; LogLevel.INFO,
} "Database",
} `Database is online, now serving ${postCount} posts`
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 export { config, server };
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`
)}`
);
console.log(
`${chalk.green(``)} ${chalk.bold(`Database is ${chalk.blue("online")}`)}`
);
// Print "serving x posts"
console.log(
`${chalk.green(``)} ${chalk.bold(
`Serving ${chalk.blue(postCount)} posts`
)}`
);

View file

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

View file

@ -16,7 +16,7 @@ export enum LogLevel {
export class LogManager { export class LogManager {
constructor(private output: BunFile) { constructor(private output: BunFile) {
void this.write( 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) { async logError(level: LogLevel, entity: string, error: Error) {
await this.log(level, entity, error.message); 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 // 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 type fs from "fs/promises";
import { import {
describe, describe,
@ -91,4 +91,141 @@ describe("LogManager", () => {
expect.stringContaining("[ERROR] TestEntity: Test error") 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 { RouteHandler } from "~server/api/routes.type";
import type { AuthData } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api"; import type { APIRouteMeta } from "~types/api";
const serverPath = process.cwd() + "/server/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 // This is to allow for compilation of the routes, so that we can minify them and
// node_modules in production // node_modules in production
export const rawRoutes = { export const rawRoutes = {
"/api/v1/accounts": import(serverPath + "/api/v1/accounts/index.ts"), "/api/v1/accounts": await import(serverPath + "/api/v1/accounts/index.ts"),
"/api/v1/accounts/familiar_followers": import( "/api/v1/accounts/familiar_followers": await import(
serverPath + "/api/v1/accounts/familiar_followers/index.ts" 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" 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" 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" 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" serverPath + "/api/v1/accounts/verify_credentials/index.ts"
), ),
"/api/v1/apps": import(serverPath + "/api/v1/apps/index.ts"), "/api/v1/apps": await import(serverPath + "/api/v1/apps/index.ts"),
"/api/v1/apps/verify_credentials": import( "/api/v1/apps/verify_credentials": await import(
serverPath + "/api/v1/apps/verify_credentials/index.ts" serverPath + "/api/v1/apps/verify_credentials/index.ts"
), ),
"/api/v1/blocks": import(serverPath + "/api/v1/blocks/index.ts"), "/api/v1/blocks": await import(serverPath + "/api/v1/blocks/index.ts"),
"/api/v1/custom_emojis": import( "/api/v1/custom_emojis": await import(
serverPath + "/api/v1/custom_emojis/index.ts" serverPath + "/api/v1/custom_emojis/index.ts"
), ),
"/api/v1/favourites": import(serverPath + "/api/v1/favourites/index.ts"), "/api/v1/favourites": await import(
"/api/v1/follow_requests": import( serverPath + "/api/v1/favourites/index.ts"
),
"/api/v1/follow_requests": await import(
serverPath + "/api/v1/follow_requests/index.ts" serverPath + "/api/v1/follow_requests/index.ts"
), ),
"/api/v1/instance": import(serverPath + "/api/v1/instance/index.ts"), "/api/v1/instance": await import(serverPath + "/api/v1/instance/index.ts"),
"/api/v1/media": import(serverPath + "/api/v1/media/index.ts"), "/api/v1/media": await import(serverPath + "/api/v1/media/index.ts"),
"/api/v1/mutes": import(serverPath + "/api/v1/mutes/index.ts"), "/api/v1/mutes": await import(serverPath + "/api/v1/mutes/index.ts"),
"/api/v1/notifications": import( "/api/v1/notifications": await import(
serverPath + "/api/v1/notifications/index.ts" serverPath + "/api/v1/notifications/index.ts"
), ),
"/api/v1/profile/avatar": import(serverPath + "/api/v1/profile/avatar.ts"), "/api/v1/profile/avatar": await import(
"/api/v1/profile/header": import(serverPath + "/api/v1/profile/header.ts"), serverPath + "/api/v1/profile/avatar.ts"
"/api/v1/statuses": import(serverPath + "/api/v1/statuses/index.ts"), ),
"/api/v1/timelines/home": import(serverPath + "/api/v1/timelines/home.ts"), "/api/v1/profile/header": await import(
"/api/v1/timelines/public": 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" serverPath + "/api/v1/timelines/public.ts"
), ),
"/api/v2/media": import(serverPath + "/api/v2/media/index.ts"), "/api/v2/media": await import(serverPath + "/api/v2/media/index.ts"),
"/api/v2/search": import(serverPath + "/api/v2/search/index.ts"), "/api/v2/search": await import(serverPath + "/api/v2/search/index.ts"),
"/auth/login": import(serverPath + "/auth/login/index.ts"), "/auth/login": await import(serverPath + "/auth/login/index.ts"),
"/nodeinfo/2.0": import(serverPath + "/nodeinfo/2.0/index.ts"), "/nodeinfo/2.0": await import(serverPath + "/nodeinfo/2.0/index.ts"),
"/oauth/authorize-external": import( "/oauth/authorize-external": await import(
serverPath + "/oauth/authorize-external/index.ts" serverPath + "/oauth/authorize-external/index.ts"
), ),
"/oauth/providers": import(serverPath + "/oauth/providers/index.ts"), "/oauth/providers": await import(serverPath + "/oauth/providers/index.ts"),
"/oauth/token": import(serverPath + "/oauth/token/index.ts"), "/oauth/token": await import(serverPath + "/oauth/token/index.ts"),
"/api/v1/accounts/[id]": import( "/api/v1/accounts/[id]": await import(
serverPath + "/api/v1/accounts/[id]/index.ts" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" serverPath + "/api/v1/follow_requests/[account_id]/reject.ts"
), ),
"/api/v1/media/[id]": import(serverPath + "/api/v1/media/[id]/index.ts"), "/api/v1/media/[id]": await import(
"/api/v1/statuses/[id]": import( serverPath + "/api/v1/media/[id]/index.ts"
),
"/api/v1/statuses/[id]": await import(
serverPath + "/api/v1/statuses/[id]/index.ts" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" serverPath + "/api/v1/statuses/[id]/unreblog.ts"
), ),
"/media/[id]": import(serverPath + "/media/[id]/index.ts"), "/media/[id]": await import(serverPath + "/media/[id]/index.ts"),
"/oauth/callback/[issuer]": import( "/oauth/callback/[issuer]": await import(
serverPath + "/oauth/callback/[issuer]/index.ts" serverPath + "/oauth/callback/[issuer]/index.ts"
), ),
"/object/[uuid]": import(serverPath + "/object/[uuid]/index.ts"), "/object/[uuid]": await import(serverPath + "/object/[uuid]/index.ts"),
"/users/[uuid]": import(serverPath + "/users/[uuid]/index.ts"), "/users/[uuid]": await import(serverPath + "/users/[uuid]/index.ts"),
"/users/[uuid]/inbox": import(serverPath + "/users/[uuid]/inbox/index.ts"), "/users/[uuid]/inbox": await import(
"/users/[uuid]/outbox": import( serverPath + "/users/[uuid]/inbox/index.ts"
),
"/users/[uuid]/outbox": await import(
serverPath + "/users/[uuid]/outbox/index.ts" serverPath + "/users/[uuid]/outbox/index.ts"
), ),
}; };
@ -158,7 +169,7 @@ export const routeMatcher = new Bun.FileSystemRouter({
dir: process.cwd() + "/server/api", dir: process.cwd() + "/server/api",
}); });
export const matchRoute = (url: string) => { export const matchRoute = <T = Record<string, never>>(url: string) => {
const route = routeMatcher.match(url); const route = routeMatcher.match(url);
if (!route) return { file: null, matchedRoute: null }; 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 // @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file
file: rawRoutes[route.name] as Promise<{ file: rawRoutes[route.name] as Promise<{
meta: APIRouteMeta; meta: APIRouteMeta;
default: ( default: RouteHandler<T>;
req: Request,
matchedRoute: MatchedRoute,
auth: AuthData
) => Response | Promise<Response>;
}>, }>,
matchedRoute: route, 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) * Find familiar followers (followers of a user that you also follow)
*/ */
export default apiRoute<{ export default apiRoute<{
"id[]": string[]; id: string[];
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = extraData.parsedRequest; const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10 // Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) { if (!ids || ids.length < 1 || ids.length > 10) {

View file

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

View file

@ -32,8 +32,8 @@ export default apiRoute<{
sensitive?: boolean; sensitive?: boolean;
language?: string; language?: string;
content_type?: string; content_type?: string;
"media_ids[]"?: string[]; media_ids?: string[];
"poll[options][]"?: string[]; "poll[options]"?: string[];
"poll[expires_in]"?: number; "poll[expires_in]"?: number;
"poll[multiple]"?: boolean; "poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean; "poll[hide_totals]"?: boolean;
@ -88,8 +88,8 @@ export default apiRoute<{
status: statusText, status: statusText,
content_type, content_type,
"poll[expires_in]": expires_in, "poll[expires_in]": expires_in,
"poll[options][]": options, "poll[options]": options,
"media_ids[]": media_ids, media_ids: media_ids,
spoiler_text, spoiler_text,
sensitive, sensitive,
} = extraData.parsedRequest; } = 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 type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ConfigManager } from "config-manager";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token"; import { TokenType } from "~database/entities/Token";
import { import {
@ -11,8 +9,10 @@ import {
} from "~database/entities/User"; } from "~database/entities/User";
import type { APIEmoji } from "~types/entities/emoji"; import type { APIEmoji } from "~types/entities/emoji";
import type { APIInstance } from "~types/entities/instance"; 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 token: Token;
let user: UserWithRelations; let user: UserWithRelations;
@ -71,14 +71,16 @@ describe("API Tests", () => {
describe("GET /api/v1/instance", () => { describe("GET /api/v1/instance", () => {
test("should return an APIInstance object", async () => { test("should return an APIInstance object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/instance`, new Request(
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
{ {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -117,15 +119,21 @@ describe("API Tests", () => {
}, },
}); });
}); });
test("should return an array of at least one custom emoji", async () => { test("should return an array of at least one custom emoji", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/custom_emojis`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/custom_emojis`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -139,6 +147,7 @@ describe("API Tests", () => {
expect(emojis[0].shortcode).toBeString(); expect(emojis[0].shortcode).toBeString();
expect(emojis[0].url).toBeString(); expect(emojis[0].url).toBeString();
}); });
afterAll(async () => { afterAll(async () => {
await client.emoji.deleteMany({ await client.emoji.deleteMany({
where: { 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 type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
@ -12,21 +9,24 @@ import {
import type { APIAccount } from "~types/entities/account"; import type { APIAccount } from "~types/entities/account";
import type { APIRelationship } from "~types/entities/relationship"; import type { APIRelationship } from "~types/entities/relationship";
import type { APIStatus } from "~types/entities/status"; 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 token: Token;
let user: UserWithRelations; let user: UserWithRelations;
let user2: UserWithRelations; let user2: UserWithRelations;
beforeAll(async () => { beforeAll(async () => {
/* await client.user.deleteMany({ await client.user.deleteMany({
where: { where: {
username: { username: {
in: ["test", "test2"], in: ["test", "test2"],
}, },
}, },
}); */ });
user = await createNewLocalUser({ user = await createNewLocalUser({
email: "test@test.com", email: "test@test.com",
@ -87,8 +87,9 @@ afterAll(async () => {
describe("API Tests", () => { describe("API Tests", () => {
describe("POST /api/v1/accounts/:id", () => { describe("POST /api/v1/accounts/:id", () => {
test("should return a 404 error when trying to fetch a non-existent user", async () => { test("should return a 404 error when trying to fetch a non-existent user", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/999999`, new Request(
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -96,6 +97,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(404); expect(response.status).toBe(404);
@ -107,8 +109,12 @@ describe("API Tests", () => {
describe("PATCH /api/v1/accounts/update_credentials", () => { describe("PATCH /api/v1/accounts/update_credentials", () => {
test("should update the authenticated user's display name", async () => { test("should update the authenticated user's display name", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/update_credentials`, new Request(
wrapRelativeUrl(
"/api/v1/accounts/update_credentials",
base_url
),
{ {
method: "PATCH", method: "PATCH",
headers: { headers: {
@ -119,6 +125,7 @@ describe("API Tests", () => {
display_name: "New Display Name", display_name: "New Display Name",
}), }),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -134,8 +141,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/verify_credentials", () => { describe("GET /api/v1/accounts/verify_credentials", () => {
test("should return the authenticated user's account information", async () => { test("should return the authenticated user's account information", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/verify_credentials`, new Request(
wrapRelativeUrl(
"/api/v1/accounts/verify_credentials",
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -143,6 +154,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -179,8 +191,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/:id/statuses", () => { describe("GET /api/v1/accounts/:id/statuses", () => {
test("should return the statuses of the specified user", async () => { test("should return the statuses of the specified user", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user.id}/statuses`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -188,6 +204,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -203,8 +220,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/follow", () => { describe("POST /api/v1/accounts/:id/follow", () => {
test("should follow the specified user and return an APIRelationship object", async () => { test("should follow the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/follow`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -213,6 +234,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -229,8 +251,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unfollow", () => { describe("POST /api/v1/accounts/:id/unfollow", () => {
test("should unfollow the specified user and return an APIRelationship object", async () => { test("should unfollow the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unfollow`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unfollow`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -239,6 +265,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -255,8 +282,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/remove_from_followers", () => { 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 () => { test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/remove_from_followers`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/remove_from_followers`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -265,6 +296,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -281,8 +313,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/block", () => { describe("POST /api/v1/accounts/:id/block", () => {
test("should block the specified user and return an APIRelationship object", async () => { test("should block the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/block`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/block`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -291,6 +327,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -307,14 +344,13 @@ describe("API Tests", () => {
describe("GET /api/v1/blocks", () => { describe("GET /api/v1/blocks", () => {
test("should return an array of APIAccount objects for the user's blocked accounts", async () => { test("should return an array of APIAccount objects for the user's blocked accounts", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/blocks`, new Request(wrapRelativeUrl("/api/v1/blocks", base_url), {
{
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
}, },
} })
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -331,8 +367,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unblock", () => { describe("POST /api/v1/accounts/:id/unblock", () => {
test("should unblock the specified user and return an APIRelationship object", async () => { test("should unblock the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unblock`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unblock`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -341,6 +381,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -357,8 +398,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => { 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 () => { test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/mute`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -367,6 +412,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({ notifications: true }), body: JSON.stringify({ notifications: true }),
} }
)
); );
expect(response.status).toBe(200); 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 () => { test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/mute`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -392,6 +442,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({ notifications: false }), body: JSON.stringify({ notifications: false }),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -409,14 +460,13 @@ describe("API Tests", () => {
describe("GET /api/v1/mutes", () => { describe("GET /api/v1/mutes", () => {
test("should return an array of APIAccount objects for the user's muted accounts", async () => { test("should return an array of APIAccount objects for the user's muted accounts", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/mutes`, new Request(wrapRelativeUrl("/api/v1/mutes", base_url), {
{
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
}, },
} })
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -434,8 +484,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unmute", () => { describe("POST /api/v1/accounts/:id/unmute", () => {
test("should unmute the specified user and return an APIRelationship object", async () => { test("should unmute the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unmute`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unmute`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -444,6 +498,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -460,8 +515,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/pin", () => { describe("POST /api/v1/accounts/:id/pin", () => {
test("should pin the specified user and return an APIRelationship object", async () => { test("should pin the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/pin`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/pin`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -470,6 +529,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -486,8 +546,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/unpin", () => { describe("POST /api/v1/accounts/:id/unpin", () => {
test("should unpin the specified user and return an APIRelationship object", async () => { test("should unpin the specified user and return an APIRelationship object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/unpin`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/unpin`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -496,6 +560,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -512,8 +577,12 @@ describe("API Tests", () => {
describe("POST /api/v1/accounts/:id/note", () => { describe("POST /api/v1/accounts/:id/note", () => {
test("should update the specified account's note and return the updated account object", async () => { test("should update the specified account's note and return the updated account object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/note`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/note`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -522,6 +591,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({ comment: "This is a new note" }), body: JSON.stringify({ comment: "This is a new note" }),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -538,14 +608,19 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/relationships", () => { describe("GET /api/v1/accounts/relationships", () => {
test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => { test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/relationships?id[]=${user2.id}`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/relationships?id[]=${user2.id}`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -571,8 +646,9 @@ describe("API Tests", () => {
describe("DELETE /api/v1/profile/avatar", () => { describe("DELETE /api/v1/profile/avatar", () => {
test("should delete the avatar of the authenticated user and return the updated account object", async () => { test("should delete the avatar of the authenticated user and return the updated account object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/profile/avatar`, new Request(
wrapRelativeUrl("/api/v1/profile/avatar", base_url),
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@ -580,6 +656,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -596,8 +673,9 @@ describe("API Tests", () => {
describe("DELETE /api/v1/profile/header", () => { describe("DELETE /api/v1/profile/header", () => {
test("should delete the header of the authenticated user and return the updated account object", async () => { test("should delete the header of the authenticated user and return the updated account object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/profile/header`, new Request(
wrapRelativeUrl("/api/v1/profile/header", base_url),
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@ -605,6 +683,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -621,8 +700,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/familiar_followers", () => { describe("GET /api/v1/accounts/familiar_followers", () => {
test("should follow the user", async () => { test("should follow the user", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user2.id}/follow`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -631,6 +714,7 @@ describe("API Tests", () => {
}, },
body: JSON.stringify({}), body: JSON.stringify({}),
} }
)
); );
expect(response.status).toBe(200); 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 () => { 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( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/familiar_followers?id[]=${user2.id}`, new Request(
wrapRelativeUrl(
`/api/v1/accounts/familiar_followers?id[]=${user2.id}`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
}, },
} }
)
); );
expect(response.status).toBe(200); 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 type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { client } from "~database/datasource"; 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 { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIContext } from "~types/entities/context"; import type { APIContext } from "~types/entities/context";
import type { APIStatus } from "~types/entities/status"; 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 token: Token;
let user: UserWithRelations; let user: UserWithRelations;
@ -86,8 +86,9 @@ describe("API Tests", () => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", new Blob(["test"], { type: "text/plain" })); formData.append("file", new Blob(["test"], { type: "text/plain" }));
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v2/media`, new Request(
wrapRelativeUrl(`${base_url}/api/v2/media`, base_url),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -95,6 +96,7 @@ describe("API Tests", () => {
}, },
body: formData, body: formData,
} }
)
); );
expect(response.status).toBe(202); expect(response.status).toBe(202);
@ -112,8 +114,9 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses", () => { describe("POST /api/v1/statuses", () => {
test("should create a new status and return an APIStatus object", async () => { test("should create a new status and return an APIStatus object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses`, new Request(
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -126,6 +129,7 @@ describe("API Tests", () => {
media_ids: [media1?.id], media_ids: [media1?.id],
}), }),
} }
)
); );
expect(response.status).toBe(200); 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 () => { test("should create a new status in reply to the previous one", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses`, new Request(
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -172,6 +177,7 @@ describe("API Tests", () => {
in_reply_to_id: status?.id, in_reply_to_id: status?.id,
}), }),
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -206,14 +212,20 @@ describe("API Tests", () => {
describe("GET /api/v1/statuses/:id", () => { describe("GET /api/v1/statuses/:id", () => {
test("should return the specified status object", async () => { test("should return the specified status object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -251,8 +263,12 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/reblog", () => { describe("POST /api/v1/statuses/:id/reblog", () => {
test("should reblog the specified status and return the reblogged status object", async () => { test("should reblog the specified status and return the reblogged status object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}/reblog`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/reblog`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -260,6 +276,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -277,8 +294,12 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/unreblog", () => { describe("POST /api/v1/statuses/:id/unreblog", () => {
test("should unreblog the specified status and return the original status object", async () => { test("should unreblog the specified status and return the original status object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}/unreblog`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/unreblog`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -286,6 +307,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -302,8 +324,12 @@ describe("API Tests", () => {
describe("GET /api/v1/statuses/:id/context", () => { describe("GET /api/v1/statuses/:id/context", () => {
test("should return the context of the specified status", async () => { test("should return the context of the specified status", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}/context`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/context`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -311,6 +337,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -330,14 +357,20 @@ describe("API Tests", () => {
describe("GET /api/v1/timelines/public", () => { describe("GET /api/v1/timelines/public", () => {
test("should return an array of APIStatus objects that includes the created status", async () => { test("should return an array of APIStatus objects that includes the created status", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/timelines/public`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/timelines/public`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -353,8 +386,12 @@ describe("API Tests", () => {
describe("GET /api/v1/accounts/:id/statuses", () => { describe("GET /api/v1/accounts/:id/statuses", () => {
test("should return the statuses of the specified user", async () => { test("should return the statuses of the specified user", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/accounts/${user.id}/statuses`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -362,6 +399,7 @@ describe("API Tests", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -384,14 +422,20 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/favourite", () => { describe("POST /api/v1/statuses/:id/favourite", () => {
test("should favourite the specified status object", async () => { test("should favourite the specified status object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}/favourite`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/favourite`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -400,14 +444,20 @@ describe("API Tests", () => {
describe("GET /api/v1/statuses/:id/favourited_by", () => { describe("GET /api/v1/statuses/:id/favourited_by", () => {
test("should return an array of User objects who favourited the specified status", async () => { test("should return an array of User objects who favourited the specified status", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}/favourited_by`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/favourited_by`,
base_url
),
{ {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -425,14 +475,20 @@ describe("API Tests", () => {
describe("POST /api/v1/statuses/:id/unfavourite", () => { describe("POST /api/v1/statuses/:id/unfavourite", () => {
test("should unfavourite the specified status object", async () => { test("should unfavourite the specified status object", async () => {
// Unfavourite the status // Unfavourite the status
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}/unfavourite`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}/unfavourite`,
base_url
),
{ {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@ -449,14 +505,19 @@ describe("API Tests", () => {
describe("DELETE /api/v1/statuses/:id", () => { describe("DELETE /api/v1/statuses/:id", () => {
test("should delete the specified status object", async () => { test("should delete the specified status object", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/statuses/${status?.id}`, new Request(
wrapRelativeUrl(
`${base_url}/api/v1/statuses/${status?.id}`,
base_url
),
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
}, },
} }
)
); );
expect(response.status).toBe(200); 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 type { Application, Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User"; 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_id: string;
let client_secret: string; let client_secret: string;
@ -30,10 +31,12 @@ describe("POST /api/v1/apps/", () => {
formData.append("redirect_uris", "https://example.com"); formData.append("redirect_uris", "https://example.com");
formData.append("scopes", "read write"); 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", method: "POST",
body: formData, body: formData,
}); })
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json"); 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("email", "test@test.com");
formData.append("password", "test"); formData.append("password", "test");
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, new Request(
wrapRelativeUrl(
`/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
base_url
),
{ {
method: "POST", method: "POST",
body: formData, body: formData,
redirect: "manual",
} }
)
); );
expect(response.status).toBe(302); expect(response.status).toBe(302);
expect(response.headers.get("Location")).toMatch( expect(response.headers.get("Location")).toMatch(
/https:\/\/example.com\?code=/ /https:\/\/example.com\?code=/
@ -94,11 +102,12 @@ describe("POST /oauth/token/", () => {
formData.append("client_secret", client_secret); formData.append("client_secret", client_secret);
formData.append("scope", "read+write"); 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", method: "POST",
// Do not set the Content-Type header for some reason
body: formData, body: formData,
}); })
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = await response.json(); const json = await response.json();
@ -119,15 +128,15 @@ describe("POST /oauth/token/", () => {
describe("GET /api/v1/apps/verify_credentials", () => { describe("GET /api/v1/apps/verify_credentials", () => {
test("should return the authenticated application's credentials", async () => { test("should return the authenticated application's credentials", async () => {
const response = await fetch( const response = await sendTestRequest(
`${config.http.base_url}/api/v1/apps/verify_credentials`, new Request(
wrapRelativeUrl("/api/v1/apps/verify_credentials", base_url),
{ {
method: "GET",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
}, },
} }
)
); );
expect(response.status).toBe(200); 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 content_type?: string
) => { ) => {
if (content_type === "text/markdown") { 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") { } else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM // Parse as MFM
// TODO: Implement MFM // TODO: Implement MFM

View file

@ -1,17 +1,18 @@
import { getConfig } from "~classes/configmanager";
import chalk from "chalk"; import chalk from "chalk";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { Meilisearch } from "meilisearch"; import { Meilisearch } from "meilisearch";
import type { Status, User } from "@prisma/client"; 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({ export const meilisearch = new Meilisearch({
host: `${config.meilisearch.host}:${config.meilisearch.port}`, host: `${config.meilisearch.host}:${config.meilisearch.port}`,
apiKey: config.meilisearch.api_key, apiKey: config.meilisearch.api_key,
}); });
export const connectMeili = async () => { export const connectMeili = async (logger: MultiLogManager | LogManager) => {
if (!config.meilisearch.enabled) return; if (!config.meilisearch.enabled) return;
if (await meilisearch.isHealthy()) { if (await meilisearch.isHealthy()) {
@ -31,14 +32,16 @@ export const connectMeili = async () => {
.index(MeiliIndexType.Statuses) .index(MeiliIndexType.Statuses)
.updateSearchableAttributes(["content"]); .updateSearchableAttributes(["content"]);
console.log( await logger.log(
`${chalk.green(``)} ${chalk.bold(`Connected to Meilisearch`)}` LogLevel.INFO,
"Meilisearch",
"Connected to Meilisearch"
); );
} else { } else {
console.error( await logger.log(
`${chalk.red(``)} ${chalk.bold( LogLevel.CRITICAL,
`Error while connecting to Meilisearch` "Meilisearch",
)}` "Error while connecting to Meilisearch"
); );
process.exit(1); 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 type { Prisma } from "@prisma/client";
import chalk from "chalk"; import chalk from "chalk";
import { ConfigManager } from "config-manager";
import Redis from "ioredis"; import Redis from "ioredis";
import { createPrismaRedisCache } from "prisma-redis-middleware"; import { createPrismaRedisCache } from "prisma-redis-middleware";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
const cacheRedis = config.redis.cache.enabled const cacheRedis = config.redis.cache.enabled
? new Redis({ ? 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"; import { sanitize } from "isomorphic-dompurify";
export const sanitizeHtml = async (html: string) => { export const sanitizeHtml = async (html: string) => {
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
const sanitizedHtml = sanitize(html, { const sanitizedHtml = sanitize(html, {
ALLOWED_TAGS: [ ALLOWED_TAGS: [