mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 00:18:19 +01:00
Finish full rewrite of server and testing systems
This commit is contained in:
parent
0e4d6b401c
commit
0541776d3d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -44,7 +44,7 @@ export const federationWorker = new Worker(
|
|||
instanceId: {
|
||||
not: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
: {},
|
||||
// Mentioned users
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
@ -211,7 +211,7 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
|
|||
? {
|
||||
status: replyStatus,
|
||||
user: (replyStatus as any).author,
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
quote: quotingStatus || undefined,
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
@ -387,7 +389,7 @@ export const createNewStatus = async (data: {
|
|||
id: attachment,
|
||||
};
|
||||
}),
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
inReplyToPostId: data.reply?.status.id,
|
||||
quotingPostId: data.quote?.id,
|
||||
|
|
@ -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 {
|
||||
|
|
@ -519,7 +523,7 @@ export const editStatus = async (
|
|||
id: attachment,
|
||||
};
|
||||
}),
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
mentions: {
|
||||
connect: mentions.map(mention => {
|
||||
|
|
@ -606,15 +610,15 @@ export const statusToAPI = async (
|
|||
quote: status.quotingPost
|
||||
? await statusToAPI(
|
||||
status.quotingPost as unknown as StatusWithRelations
|
||||
)
|
||||
)
|
||||
: null,
|
||||
quote_id: status.quotingPost?.id || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
199
index.ts
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
147
routes.ts
|
|
@ -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
157
server.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
// Empty 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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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,15 +87,17 @@ 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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -107,18 +109,23 @@ 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`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
display_name: "New Display Name",
|
||||
}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
"/api/v1/accounts/update_credentials",
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
display_name: "New Display Name",
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -134,15 +141,20 @@ 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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
"/api/v1/accounts/verify_credentials",
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -179,15 +191,20 @@ 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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user.id}/statuses`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -203,16 +220,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/follow`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -229,16 +251,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/unfollow`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -255,16 +282,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/remove_from_followers`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -281,16 +313,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/block`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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,16 +367,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/unblock`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -357,16 +398,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ notifications: true }),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/mute`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ notifications: true }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -382,16 +428,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ notifications: false }),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/mute`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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,16 +484,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/unmute`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -460,16 +515,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/pin`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -486,16 +546,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/unpin`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -512,16 +577,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ comment: "This is a new note" }),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/note`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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,15 +646,17 @@ 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`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl("/api/v1/profile/avatar", base_url),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -596,15 +673,17 @@ 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`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl("/api/v1/profile/header", base_url),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -621,16 +700,21 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`/api/v1/accounts/${user2.id}/follow`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,15 +86,17 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(`${base_url}/api/v2/media`, base_url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
|
|
@ -112,20 +114,22 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "Hello, world!",
|
||||
visibility: "public",
|
||||
media_ids: [media1?.id],
|
||||
}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "Hello, world!",
|
||||
visibility: "public",
|
||||
media_ids: [media1?.id],
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -158,20 +162,22 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "This is a reply!",
|
||||
visibility: "public",
|
||||
in_reply_to_id: status?.id,
|
||||
}),
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "This is a reply!",
|
||||
visibility: "public",
|
||||
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}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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,15 +263,20 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`${base_url}/api/v1/statuses/${status?.id}/reblog`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -277,15 +294,20 @@ 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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`${base_url}/api/v1/statuses/${status?.id}/unreblog`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -302,15 +324,20 @@ 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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`${base_url}/api/v1/statuses/${status?.id}/context`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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,15 +386,20 @@ 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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl(
|
||||
`${base_url}/api/v1/accounts/${user.id}/statuses`,
|
||||
base_url
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
*/
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
// Empty 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/`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
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`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual",
|
||||
}
|
||||
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,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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/`, {
|
||||
method: "POST",
|
||||
// Do not set the Content-Type header for some reason
|
||||
body: formData,
|
||||
});
|
||||
const response = await sendTestRequest(
|
||||
new Request(wrapRelativeUrl("/oauth/token/", base_url), {
|
||||
method: "POST",
|
||||
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`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
wrapRelativeUrl("/api/v1/apps/verify_credentials", base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.access_token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
|
|||
15
tests/utils.ts
Normal file
15
tests/utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
31
utils/module.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
@ -12,7 +12,7 @@ const cacheRedis = config.redis.cache.enabled
|
|||
port: Number(config.redis.cache.port),
|
||||
password: config.redis.cache.password,
|
||||
db: Number(config.redis.cache.database ?? 0),
|
||||
})
|
||||
})
|
||||
: null;
|
||||
|
||||
cacheRedis?.on("error", e => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
*/
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue