mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28: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_requests = true
|
||||||
# Log request and their contents (warning: this is a lot of data)
|
# Log request and their contents (warning: this is a lot of data)
|
||||||
log_requests_verbose = false
|
log_requests_verbose = false
|
||||||
|
# For GDPR compliance, you can disable logging of IPs
|
||||||
|
log_ip = false
|
||||||
|
|
||||||
# Log all filtered objects
|
# Log all filtered objects
|
||||||
log_filters = true
|
log_filters = true
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { getConfig } from "../utils/config";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
const client = new PrismaClient({
|
const client = new PrismaClient({
|
||||||
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
|
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
import type { Like as LysandLike } from "~types/lysand/Object";
|
import type { Like as LysandLike } from "~types/lysand/Object";
|
||||||
import { getConfig } from "~classes/configmanager";
|
|
||||||
import type { Like } from "@prisma/client";
|
import type { Like } from "@prisma/client";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import type { UserWithRelations } from "./User";
|
import type { UserWithRelations } from "./User";
|
||||||
import type { StatusWithRelations } from "./Status";
|
import type { StatusWithRelations } from "./Status";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
|
||||||
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a Like entity in the database.
|
* Represents a Like entity in the database.
|
||||||
|
|
@ -16,7 +18,7 @@ export const toLysand = (like: Like): LysandLike => {
|
||||||
type: "Like",
|
type: "Like",
|
||||||
created_at: new Date(like.createdAt).toISOString(),
|
created_at: new Date(like.createdAt).toISOString(),
|
||||||
object: (like as any).liked?.uri,
|
object: (like as any).liked?.uri,
|
||||||
uri: `${getConfig().http.base_url}/actions/${like.id}`,
|
uri: `${config.http.base_url}/actions/${like.id}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { getConfig } from "~classes/configmanager";
|
|
||||||
import { Worker } from "bullmq";
|
import { Worker } from "bullmq";
|
||||||
import { client, federationQueue } from "~database/datasource";
|
import { client, federationQueue } from "~database/datasource";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,8 +6,9 @@ import {
|
||||||
type StatusWithRelations,
|
type StatusWithRelations,
|
||||||
} from "./Status";
|
} from "./Status";
|
||||||
import type { User } from "@prisma/client";
|
import type { User } from "@prisma/client";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
export const federationWorker = new Worker(
|
export const federationWorker = new Worker(
|
||||||
"federation",
|
"federation",
|
||||||
|
|
@ -44,7 +44,7 @@ export const federationWorker = new Worker(
|
||||||
instanceId: {
|
instanceId: {
|
||||||
not: null,
|
not: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
// Mentioned users
|
// Mentioned users
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
import { getConfig } from "~classes/configmanager";
|
|
||||||
import type { UserWithRelations } from "./User";
|
import type { UserWithRelations } from "./User";
|
||||||
import {
|
import {
|
||||||
fetchRemoteUser,
|
fetchRemoteUser,
|
||||||
|
|
@ -29,8 +28,9 @@ import { parse } from "marked";
|
||||||
import linkifyStr from "linkify-string";
|
import linkifyStr from "linkify-string";
|
||||||
import linkifyHtml from "linkify-html";
|
import linkifyHtml from "linkify-html";
|
||||||
import { addStausToMeilisearch } from "@meilisearch";
|
import { addStausToMeilisearch } from "@meilisearch";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
export const statusAndUserRelations: Prisma.StatusInclude = {
|
export const statusAndUserRelations: Prisma.StatusInclude = {
|
||||||
author: {
|
author: {
|
||||||
|
|
@ -211,7 +211,7 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
|
||||||
? {
|
? {
|
||||||
status: replyStatus,
|
status: replyStatus,
|
||||||
user: (replyStatus as any).author,
|
user: (replyStatus as any).author,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
quote: quotingStatus || undefined,
|
quote: quotingStatus || undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -349,7 +349,9 @@ export const createNewStatus = async (data: {
|
||||||
|
|
||||||
// Get HTML version of content
|
// Get HTML version of content
|
||||||
if (data.content_type === "text/markdown") {
|
if (data.content_type === "text/markdown") {
|
||||||
formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content)));
|
formattedContent = linkifyHtml(
|
||||||
|
await sanitizeHtml(await parse(data.content))
|
||||||
|
);
|
||||||
} else if (data.content_type === "text/x.misskeymarkdown") {
|
} else if (data.content_type === "text/x.misskeymarkdown") {
|
||||||
// Parse as MFM
|
// Parse as MFM
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -387,7 +389,7 @@ export const createNewStatus = async (data: {
|
||||||
id: attachment,
|
id: attachment,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
inReplyToPostId: data.reply?.status.id,
|
inReplyToPostId: data.reply?.status.id,
|
||||||
quotingPostId: data.quote?.id,
|
quotingPostId: data.quote?.id,
|
||||||
|
|
@ -480,7 +482,9 @@ export const editStatus = async (
|
||||||
|
|
||||||
// Get HTML version of content
|
// Get HTML version of content
|
||||||
if (data.content_type === "text/markdown") {
|
if (data.content_type === "text/markdown") {
|
||||||
formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content)));
|
formattedContent = linkifyHtml(
|
||||||
|
await sanitizeHtml(await parse(data.content))
|
||||||
|
);
|
||||||
} else if (data.content_type === "text/x.misskeymarkdown") {
|
} else if (data.content_type === "text/x.misskeymarkdown") {
|
||||||
// Parse as MFM
|
// Parse as MFM
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -519,7 +523,7 @@ export const editStatus = async (
|
||||||
id: attachment,
|
id: attachment,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
mentions: {
|
mentions: {
|
||||||
connect: mentions.map(mention => {
|
connect: mentions.map(mention => {
|
||||||
|
|
@ -606,15 +610,15 @@ export const statusToAPI = async (
|
||||||
quote: status.quotingPost
|
quote: status.quotingPost
|
||||||
? await statusToAPI(
|
? await statusToAPI(
|
||||||
status.quotingPost as unknown as StatusWithRelations
|
status.quotingPost as unknown as StatusWithRelations
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
quote_id: status.quotingPost?.id || undefined,
|
quote_id: status.quotingPost?.id || undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const statusToActivityPub = async (
|
/* export const statusToActivityPub = async (
|
||||||
status: StatusWithRelations,
|
status: StatusWithRelations
|
||||||
user?: UserWithRelations
|
// user?: UserWithRelations
|
||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
// replace any with your ActivityPub type
|
// replace any with your ActivityPub type
|
||||||
return {
|
return {
|
||||||
|
|
@ -657,7 +661,7 @@ export const statusToActivityPub = async (
|
||||||
visibility: "public", // adjust as needed
|
visibility: "public", // adjust as needed
|
||||||
// add more fields as needed
|
// add more fields as needed
|
||||||
};
|
};
|
||||||
};
|
}; */
|
||||||
|
|
||||||
export const statusToLysand = (status: StatusWithRelations): Note => {
|
export const statusToLysand = (status: StatusWithRelations): Note => {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import type { ConfigType } from "~classes/configmanager";
|
|
||||||
import { getConfig } from "~classes/configmanager";
|
|
||||||
import type { APIAccount } from "~types/entities/account";
|
import type { APIAccount } from "~types/entities/account";
|
||||||
import type { User as LysandUser } from "~types/lysand/Object";
|
import type { User as LysandUser } from "~types/lysand/Object";
|
||||||
import { htmlToText } from "html-to-text";
|
import { htmlToText } from "html-to-text";
|
||||||
|
|
@ -10,6 +8,10 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
|
||||||
import { addInstanceIfNotExists } from "./Instance";
|
import { addInstanceIfNotExists } from "./Instance";
|
||||||
import type { APISource } from "~types/entities/source";
|
import type { APISource } from "~types/entities/source";
|
||||||
import { addUserToMeilisearch } from "@meilisearch";
|
import { addUserToMeilisearch } from "@meilisearch";
|
||||||
|
import { ConfigManager, type ConfigType } from "config-manager";
|
||||||
|
|
||||||
|
const configManager = new ConfigManager({});
|
||||||
|
const config = await configManager.getConfig();
|
||||||
|
|
||||||
export interface AuthData {
|
export interface AuthData {
|
||||||
user: UserWithRelations | null;
|
user: UserWithRelations | null;
|
||||||
|
|
@ -201,7 +203,7 @@ export const createNewLocalUser = async (data: {
|
||||||
header?: string;
|
header?: string;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const config = getConfig();
|
const config = await configManager.getConfig();
|
||||||
|
|
||||||
const keys = await generateUserKeys();
|
const keys = await generateUserKeys();
|
||||||
|
|
||||||
|
|
@ -344,8 +346,6 @@ export const userToAPI = (
|
||||||
user: UserWithRelations,
|
user: UserWithRelations,
|
||||||
isOwnAccount = false
|
isOwnAccount = false
|
||||||
): APIAccount => {
|
): APIAccount => {
|
||||||
const config = getConfig();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|
@ -373,7 +373,7 @@ export const userToAPI = (
|
||||||
header_static: "",
|
header_static: "",
|
||||||
acct:
|
acct:
|
||||||
user.instance === null
|
user.instance === null
|
||||||
? `${user.username}`
|
? user.username
|
||||||
: `${user.username}@${user.instance.base_url}`,
|
: `${user.username}@${user.instance.base_url}`,
|
||||||
// TODO: Add these fields
|
// TODO: Add these fields
|
||||||
limited: false,
|
limited: false,
|
||||||
|
|
@ -424,13 +424,13 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: [
|
avatar: [
|
||||||
{
|
{
|
||||||
content: getAvatarUrl(user, getConfig()) || "",
|
content: getAvatarUrl(user, config) || "",
|
||||||
content_type: `image/${user.avatar.split(".")[1]}`,
|
content_type: `image/${user.avatar.split(".")[1]}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
header: [
|
header: [
|
||||||
{
|
{
|
||||||
content: getHeaderUrl(user, getConfig()) || "",
|
content: getHeaderUrl(user, config) || "",
|
||||||
content_type: `image/${user.header.split(".")[1]}`,
|
content_type: `image/${user.header.split(".")[1]}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -458,7 +458,7 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
public_key: {
|
public_key: {
|
||||||
actor: `${getConfig().http.base_url}/users/${user.id}`,
|
actor: `${config.http.base_url}/users/${user.id}`,
|
||||||
public_key: user.publicKey,
|
public_key: user.publicKey,
|
||||||
},
|
},
|
||||||
extensions: {
|
extensions: {
|
||||||
|
|
|
||||||
199
index.ts
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 type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
|
||||||
import { initializeRedisCache } from "@redis";
|
import { initializeRedisCache } from "@redis";
|
||||||
import { connectMeili } from "@meilisearch";
|
import { connectMeili } from "@meilisearch";
|
||||||
import { matchRoute } from "~routes";
|
import { ConfigManager } from "config-manager";
|
||||||
|
import { client } from "~database/datasource";
|
||||||
|
import { LogLevel, LogManager, MultiLogManager } from "log-manager";
|
||||||
|
import { moduleIsEntry } from "@module";
|
||||||
|
import { createServer } from "~server";
|
||||||
|
|
||||||
const timeAtStart = performance.now();
|
const timeAtStart = performance.now();
|
||||||
|
|
||||||
console.log(`${chalk.green(`>`)} ${chalk.bold("Starting Lysand...")}`);
|
const configManager = new ConfigManager({});
|
||||||
|
const config = await configManager.getConfig();
|
||||||
|
|
||||||
const config = getConfig();
|
|
||||||
const requests_log = Bun.file(process.cwd() + "/logs/requests.log");
|
const requests_log = Bun.file(process.cwd() + "/logs/requests.log");
|
||||||
|
const isEntry = moduleIsEntry(import.meta.url);
|
||||||
|
// If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests)
|
||||||
|
const logger = new LogManager(isEntry ? requests_log : Bun.file(`/dev/null`));
|
||||||
|
const consoleLogger = new LogManager(
|
||||||
|
isEntry ? Bun.stdout : Bun.file(`/dev/null`)
|
||||||
|
);
|
||||||
|
const dualLogger = new MultiLogManager([logger, consoleLogger]);
|
||||||
|
|
||||||
// Needs to be imported after config is loaded
|
await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand...");
|
||||||
import { client } from "~database/datasource";
|
|
||||||
|
|
||||||
// NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead
|
// NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead
|
||||||
const isProd =
|
const isProd =
|
||||||
process.env.NODE_ENV === "production" || process.argv.includes("--prod");
|
process.env.NODE_ENV === "production" || process.argv.includes("--prod");
|
||||||
|
|
||||||
if (!(await requests_log.exists())) {
|
|
||||||
console.log(`${chalk.green(`✓`)} ${chalk.bold("Creating logs folder...")}`);
|
|
||||||
await mkdir(process.cwd() + "/logs");
|
|
||||||
await Bun.write(process.cwd() + "/logs/requests.log", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const redisCache = await initializeRedisCache();
|
const redisCache = await initializeRedisCache();
|
||||||
|
|
||||||
if (config.meilisearch.enabled) {
|
if (config.meilisearch.enabled) {
|
||||||
await connectMeili();
|
await connectMeili(dualLogger);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redisCache) {
|
if (redisCache) {
|
||||||
|
|
@ -46,163 +43,23 @@ try {
|
||||||
postCount = await client.status.count();
|
postCount = await client.status.count();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as PrismaClientInitializationError;
|
const error = e as PrismaClientInitializationError;
|
||||||
console.error(
|
await logger.logError(LogLevel.CRITICAL, "Database", error);
|
||||||
`${chalk.red(`✗`)} ${chalk.bold(
|
await consoleLogger.logError(LogLevel.CRITICAL, "Database", error);
|
||||||
"Error while connecting to database: "
|
|
||||||
)} ${error.message}`
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
Bun.serve({
|
const server = createServer(config, configManager, dualLogger, isProd);
|
||||||
port: config.http.bind_port,
|
|
||||||
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
|
|
||||||
async fetch(req) {
|
|
||||||
/* Check for banned IPs */
|
|
||||||
const request_ip = this.requestIP(req)?.address ?? "";
|
|
||||||
|
|
||||||
for (const ip of config.http.banned_ips) {
|
await dualLogger.log(
|
||||||
try {
|
LogLevel.INFO,
|
||||||
if (matches(ip, request_ip)) {
|
"Server",
|
||||||
return new Response(undefined, {
|
`Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`
|
||||||
status: 403,
|
|
||||||
statusText: "Forbidden",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[-] Error while parsing banned IP "${ip}" `);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await logRequest(req);
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return jsonResponse({});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { file, matchedRoute } = matchRoute(req.url);
|
|
||||||
|
|
||||||
if (matchedRoute) {
|
|
||||||
const meta = (await file).meta;
|
|
||||||
|
|
||||||
// Check for allowed requests
|
|
||||||
if (!meta.allowedMethods.includes(req.method as any)) {
|
|
||||||
return new Response(undefined, {
|
|
||||||
status: 405,
|
|
||||||
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
|
|
||||||
", "
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Check for ratelimits
|
|
||||||
const auth = await getFromRequest(req);
|
|
||||||
|
|
||||||
// Check for authentication if required
|
|
||||||
if (meta.auth.required) {
|
|
||||||
if (!auth.user) {
|
|
||||||
return new Response(undefined, {
|
|
||||||
status: 401,
|
|
||||||
statusText: "Unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
(meta.auth.requiredOnMethods ?? []).includes(req.method as any)
|
|
||||||
) {
|
|
||||||
if (!auth.user) {
|
|
||||||
return new Response(undefined, {
|
|
||||||
status: 401,
|
|
||||||
statusText: "Unauthorized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await (await file).default(req.clone(), matchedRoute, auth);
|
|
||||||
} else {
|
|
||||||
// Proxy response from Vite at localhost:5173 if in development mode
|
|
||||||
if (isProd) {
|
|
||||||
if (new URL(req.url).pathname.startsWith("/assets")) {
|
|
||||||
// Serve from pages/dist/assets
|
|
||||||
return new Response(
|
|
||||||
Bun.file(`./pages/dist${new URL(req.url).pathname}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve from pages/dist
|
|
||||||
return new Response(Bun.file(`./pages/dist/index.html`));
|
|
||||||
} else {
|
|
||||||
const proxy = await fetch(
|
|
||||||
req.url.replace(
|
|
||||||
config.http.base_url,
|
|
||||||
"http://localhost:5173"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (proxy.status !== 404) {
|
|
||||||
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`
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
await dualLogger.log(
|
||||||
`${chalk.green(`✓`)} ${chalk.bold(`Database is ${chalk.blue("online")}`)}`
|
LogLevel.INFO,
|
||||||
|
"Database",
|
||||||
|
`Database is online, now serving ${postCount} posts`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Print "serving x posts"
|
export { config, server };
|
||||||
console.log(
|
|
||||||
`${chalk.green(`✓`)} ${chalk.bold(
|
|
||||||
`Serving ${chalk.blue(postCount)} posts`
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ export interface ConfigType {
|
||||||
logging: {
|
logging: {
|
||||||
log_requests: boolean;
|
log_requests: boolean;
|
||||||
log_requests_verbose: boolean;
|
log_requests_verbose: boolean;
|
||||||
|
log_ip: boolean;
|
||||||
log_filters: boolean;
|
log_filters: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -351,6 +352,7 @@ export const configDefaults: ConfigType = {
|
||||||
logging: {
|
logging: {
|
||||||
log_requests: false,
|
log_requests: false,
|
||||||
log_requests_verbose: false,
|
log_requests_verbose: false,
|
||||||
|
log_ip: false,
|
||||||
log_filters: true,
|
log_filters: true,
|
||||||
},
|
},
|
||||||
ratelimits: {
|
ratelimits: {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export enum LogLevel {
|
||||||
export class LogManager {
|
export class LogManager {
|
||||||
constructor(private output: BunFile) {
|
constructor(private output: BunFile) {
|
||||||
void this.write(
|
void this.write(
|
||||||
`--- INIT LogManager at ${new Date().toISOString()} --`
|
`--- INIT LogManager at ${new Date().toISOString()} ---`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,4 +58,114 @@ export class LogManager {
|
||||||
async logError(level: LogLevel, entity: string, error: Error) {
|
async logError(level: LogLevel, entity: string, error: Error) {
|
||||||
await this.log(level, entity, error.message);
|
await this.log(level, entity, error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a request to the output
|
||||||
|
* @param req Request to log
|
||||||
|
* @param ip IP of the request
|
||||||
|
* @param logAllDetails Whether to log all details of the request
|
||||||
|
*/
|
||||||
|
async logRequest(req: Request, ip?: string, logAllDetails = false) {
|
||||||
|
let string = ip ? `${ip}: ` : "";
|
||||||
|
|
||||||
|
string += `${req.method} ${req.url}`;
|
||||||
|
|
||||||
|
if (logAllDetails) {
|
||||||
|
string += `\n`;
|
||||||
|
string += ` [Headers]\n`;
|
||||||
|
// Pretty print headers
|
||||||
|
for (const [key, value] of req.headers.entries()) {
|
||||||
|
string += ` ${key}: ${value}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretty print body
|
||||||
|
string += ` [Body]\n`;
|
||||||
|
const content_type = req.headers.get("Content-Type");
|
||||||
|
|
||||||
|
if (content_type && content_type.includes("application/json")) {
|
||||||
|
const json = await req.json();
|
||||||
|
const stringified = JSON.stringify(json, null, 4)
|
||||||
|
.split("\n")
|
||||||
|
.map(line => ` ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
string += `${stringified}\n`;
|
||||||
|
} else if (
|
||||||
|
content_type &&
|
||||||
|
(content_type.includes("application/x-www-form-urlencoded") ||
|
||||||
|
content_type.includes("multipart/form-data"))
|
||||||
|
) {
|
||||||
|
const formData = await req.formData();
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value.toString().length < 300) {
|
||||||
|
string += ` ${key}: ${value.toString()}\n`;
|
||||||
|
} else {
|
||||||
|
string += ` ${key}: <${value.toString().length} bytes>\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await req.text();
|
||||||
|
string += ` ${text}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.log(LogLevel.INFO, "Request", string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outputs to multiple LogManager instances at once
|
||||||
|
*/
|
||||||
|
export class MultiLogManager {
|
||||||
|
constructor(private logManagers: LogManager[]) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a message to all logManagers
|
||||||
|
* @param level Importance of the log
|
||||||
|
* @param entity Emitter of the log
|
||||||
|
* @param message Message to log
|
||||||
|
* @param showTimestamp Whether to show the timestamp in the log
|
||||||
|
*/
|
||||||
|
async log(
|
||||||
|
level: LogLevel,
|
||||||
|
entity: string,
|
||||||
|
message: string,
|
||||||
|
showTimestamp = true
|
||||||
|
) {
|
||||||
|
for (const logManager of this.logManagers) {
|
||||||
|
await logManager.log(level, entity, message, showTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an error to all logManagers
|
||||||
|
* @param level Importance of the log
|
||||||
|
* @param entity Emitter of the log
|
||||||
|
* @param error Error to log
|
||||||
|
*/
|
||||||
|
async logError(level: LogLevel, entity: string, error: Error) {
|
||||||
|
for (const logManager of this.logManagers) {
|
||||||
|
await logManager.logError(level, entity, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a request to all logManagers
|
||||||
|
* @param req Request to log
|
||||||
|
* @param ip IP of the request
|
||||||
|
* @param logAllDetails Whether to log all details of the request
|
||||||
|
*/
|
||||||
|
async logRequest(req: Request, ip?: string, logAllDetails = false) {
|
||||||
|
for (const logManager of this.logManagers) {
|
||||||
|
await logManager.logRequest(req, ip, logAllDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MultiLogManager from multiple LogManager instances
|
||||||
|
* @param logManagers LogManager instances to use
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
static fromLogManagers(...logManagers: LogManager[]) {
|
||||||
|
return new MultiLogManager(logManagers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
|
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
|
||||||
import { LogManager, LogLevel } from "../index";
|
import { LogManager, LogLevel, MultiLogManager } from "../index";
|
||||||
import type fs from "fs/promises";
|
import type fs from "fs/promises";
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
|
|
@ -91,4 +91,141 @@ describe("LogManager", () => {
|
||||||
expect.stringContaining("[ERROR] TestEntity: Test error")
|
expect.stringContaining("[ERROR] TestEntity: Test error")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should log basic request details", async () => {
|
||||||
|
const req = new Request("http://localhost/test", { method: "GET" });
|
||||||
|
await logManager.logRequest(req, "127.0.0.1");
|
||||||
|
|
||||||
|
expect(mockAppend).toHaveBeenCalledWith(
|
||||||
|
mockOutput.name,
|
||||||
|
expect.stringContaining("127.0.0.1: GET http://localhost/test")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Request logger", () => {
|
||||||
|
it("should log all request details for JSON content type", async () => {
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ test: "value" }),
|
||||||
|
});
|
||||||
|
await logManager.logRequest(req, "127.0.0.1", true);
|
||||||
|
|
||||||
|
const expectedLog = `127.0.0.1: POST http://localhost/test
|
||||||
|
[Headers]
|
||||||
|
content-type: application/json
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"test": "value"
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(mockAppend).toHaveBeenCalledWith(
|
||||||
|
mockOutput.name,
|
||||||
|
expect.stringContaining(expectedLog)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log all request details for text content type", async () => {
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body: "Test body",
|
||||||
|
});
|
||||||
|
await logManager.logRequest(req, "127.0.0.1", true);
|
||||||
|
|
||||||
|
const expectedLog = `127.0.0.1: POST http://localhost/test
|
||||||
|
[Headers]
|
||||||
|
content-type: text/plain
|
||||||
|
[Body]
|
||||||
|
Test body
|
||||||
|
`;
|
||||||
|
expect(mockAppend).toHaveBeenCalledWith(
|
||||||
|
mockOutput.name,
|
||||||
|
expect.stringContaining(expectedLog)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log all request details for FormData content-type", async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("test", "value");
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
await logManager.logRequest(req, "127.0.0.1", true);
|
||||||
|
|
||||||
|
const expectedLog = `127.0.0.1: POST http://localhost/test
|
||||||
|
[Headers]
|
||||||
|
content-type: multipart/form-data; boundary=${
|
||||||
|
req.headers.get("Content-Type")?.split("boundary=")[1] ?? ""
|
||||||
|
}
|
||||||
|
[Body]
|
||||||
|
test: value
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(mockAppend).toHaveBeenCalledWith(
|
||||||
|
mockOutput.name,
|
||||||
|
expect.stringContaining(
|
||||||
|
expectedLog.replace("----", expect.any(String))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MultiLogManager", () => {
|
||||||
|
let multiLogManager: MultiLogManager;
|
||||||
|
let mockLogManagers: LogManager[];
|
||||||
|
let mockLog: jest.Mock;
|
||||||
|
let mockLogError: jest.Mock;
|
||||||
|
let mockLogRequest: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLog = jest.fn();
|
||||||
|
mockLogError = jest.fn();
|
||||||
|
mockLogRequest = jest.fn();
|
||||||
|
mockLogManagers = [
|
||||||
|
{
|
||||||
|
log: mockLog,
|
||||||
|
logError: mockLogError,
|
||||||
|
logRequest: mockLogRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
log: mockLog,
|
||||||
|
logError: mockLogError,
|
||||||
|
logRequest: mockLogRequest,
|
||||||
|
},
|
||||||
|
] as unknown as LogManager[];
|
||||||
|
multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log message to all logManagers", async () => {
|
||||||
|
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
|
||||||
|
expect(mockLog).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(
|
||||||
|
LogLevel.INFO,
|
||||||
|
"TestEntity",
|
||||||
|
"Test message",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log error to all logManagers", async () => {
|
||||||
|
const error = new Error("Test error");
|
||||||
|
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
|
||||||
|
expect(mockLogError).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLogError).toHaveBeenCalledWith(
|
||||||
|
LogLevel.ERROR,
|
||||||
|
"TestEntity",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log request to all logManagers", async () => {
|
||||||
|
const req = new Request("http://localhost/test", { method: "GET" });
|
||||||
|
await multiLogManager.logRequest(req, "127.0.0.1", true);
|
||||||
|
expect(mockLogRequest).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
147
routes.ts
147
routes.ts
|
|
@ -1,5 +1,4 @@
|
||||||
import type { MatchedRoute } from "bun";
|
import type { RouteHandler } from "~server/api/routes.type";
|
||||||
import type { AuthData } from "~database/entities/User";
|
|
||||||
import type { APIRouteMeta } from "~types/api";
|
import type { APIRouteMeta } from "~types/api";
|
||||||
|
|
||||||
const serverPath = process.cwd() + "/server/api";
|
const serverPath = process.cwd() + "/server/api";
|
||||||
|
|
@ -8,146 +7,158 @@ const serverPath = process.cwd() + "/server/api";
|
||||||
// This is to allow for compilation of the routes, so that we can minify them and
|
// This is to allow for compilation of the routes, so that we can minify them and
|
||||||
// node_modules in production
|
// node_modules in production
|
||||||
export const rawRoutes = {
|
export const rawRoutes = {
|
||||||
"/api/v1/accounts": import(serverPath + "/api/v1/accounts/index.ts"),
|
"/api/v1/accounts": await import(serverPath + "/api/v1/accounts/index.ts"),
|
||||||
"/api/v1/accounts/familiar_followers": import(
|
"/api/v1/accounts/familiar_followers": await import(
|
||||||
serverPath + "/api/v1/accounts/familiar_followers/index.ts"
|
serverPath + "/api/v1/accounts/familiar_followers/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/relationships": import(
|
"/api/v1/accounts/relationships": await import(
|
||||||
serverPath + "/api/v1/accounts/relationships/index.ts"
|
serverPath + "/api/v1/accounts/relationships/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/search": import(
|
"/api/v1/accounts/search": await import(
|
||||||
serverPath + "/api/v1/accounts/search/index.ts"
|
serverPath + "/api/v1/accounts/search/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/update_credentials": import(
|
"/api/v1/accounts/update_credentials": await import(
|
||||||
serverPath + "/api/v1/accounts/update_credentials/index.ts"
|
serverPath + "/api/v1/accounts/update_credentials/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/verify_credentials": import(
|
"/api/v1/accounts/verify_credentials": await import(
|
||||||
serverPath + "/api/v1/accounts/verify_credentials/index.ts"
|
serverPath + "/api/v1/accounts/verify_credentials/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/apps": import(serverPath + "/api/v1/apps/index.ts"),
|
"/api/v1/apps": await import(serverPath + "/api/v1/apps/index.ts"),
|
||||||
"/api/v1/apps/verify_credentials": import(
|
"/api/v1/apps/verify_credentials": await import(
|
||||||
serverPath + "/api/v1/apps/verify_credentials/index.ts"
|
serverPath + "/api/v1/apps/verify_credentials/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/blocks": import(serverPath + "/api/v1/blocks/index.ts"),
|
"/api/v1/blocks": await import(serverPath + "/api/v1/blocks/index.ts"),
|
||||||
"/api/v1/custom_emojis": import(
|
"/api/v1/custom_emojis": await import(
|
||||||
serverPath + "/api/v1/custom_emojis/index.ts"
|
serverPath + "/api/v1/custom_emojis/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/favourites": import(serverPath + "/api/v1/favourites/index.ts"),
|
"/api/v1/favourites": await import(
|
||||||
"/api/v1/follow_requests": import(
|
serverPath + "/api/v1/favourites/index.ts"
|
||||||
|
),
|
||||||
|
"/api/v1/follow_requests": await import(
|
||||||
serverPath + "/api/v1/follow_requests/index.ts"
|
serverPath + "/api/v1/follow_requests/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/instance": import(serverPath + "/api/v1/instance/index.ts"),
|
"/api/v1/instance": await import(serverPath + "/api/v1/instance/index.ts"),
|
||||||
"/api/v1/media": import(serverPath + "/api/v1/media/index.ts"),
|
"/api/v1/media": await import(serverPath + "/api/v1/media/index.ts"),
|
||||||
"/api/v1/mutes": import(serverPath + "/api/v1/mutes/index.ts"),
|
"/api/v1/mutes": await import(serverPath + "/api/v1/mutes/index.ts"),
|
||||||
"/api/v1/notifications": import(
|
"/api/v1/notifications": await import(
|
||||||
serverPath + "/api/v1/notifications/index.ts"
|
serverPath + "/api/v1/notifications/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/profile/avatar": import(serverPath + "/api/v1/profile/avatar.ts"),
|
"/api/v1/profile/avatar": await import(
|
||||||
"/api/v1/profile/header": import(serverPath + "/api/v1/profile/header.ts"),
|
serverPath + "/api/v1/profile/avatar.ts"
|
||||||
"/api/v1/statuses": import(serverPath + "/api/v1/statuses/index.ts"),
|
),
|
||||||
"/api/v1/timelines/home": import(serverPath + "/api/v1/timelines/home.ts"),
|
"/api/v1/profile/header": await import(
|
||||||
"/api/v1/timelines/public": import(
|
serverPath + "/api/v1/profile/header.ts"
|
||||||
|
),
|
||||||
|
"/api/v1/statuses": await import(serverPath + "/api/v1/statuses/index.ts"),
|
||||||
|
"/api/v1/timelines/home": await import(
|
||||||
|
serverPath + "/api/v1/timelines/home.ts"
|
||||||
|
),
|
||||||
|
"/api/v1/timelines/public": await import(
|
||||||
serverPath + "/api/v1/timelines/public.ts"
|
serverPath + "/api/v1/timelines/public.ts"
|
||||||
),
|
),
|
||||||
"/api/v2/media": import(serverPath + "/api/v2/media/index.ts"),
|
"/api/v2/media": await import(serverPath + "/api/v2/media/index.ts"),
|
||||||
"/api/v2/search": import(serverPath + "/api/v2/search/index.ts"),
|
"/api/v2/search": await import(serverPath + "/api/v2/search/index.ts"),
|
||||||
"/auth/login": import(serverPath + "/auth/login/index.ts"),
|
"/auth/login": await import(serverPath + "/auth/login/index.ts"),
|
||||||
"/nodeinfo/2.0": import(serverPath + "/nodeinfo/2.0/index.ts"),
|
"/nodeinfo/2.0": await import(serverPath + "/nodeinfo/2.0/index.ts"),
|
||||||
"/oauth/authorize-external": import(
|
"/oauth/authorize-external": await import(
|
||||||
serverPath + "/oauth/authorize-external/index.ts"
|
serverPath + "/oauth/authorize-external/index.ts"
|
||||||
),
|
),
|
||||||
"/oauth/providers": import(serverPath + "/oauth/providers/index.ts"),
|
"/oauth/providers": await import(serverPath + "/oauth/providers/index.ts"),
|
||||||
"/oauth/token": import(serverPath + "/oauth/token/index.ts"),
|
"/oauth/token": await import(serverPath + "/oauth/token/index.ts"),
|
||||||
"/api/v1/accounts/[id]": import(
|
"/api/v1/accounts/[id]": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/index.ts"
|
serverPath + "/api/v1/accounts/[id]/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/block": import(
|
"/api/v1/accounts/[id]/block": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/block.ts"
|
serverPath + "/api/v1/accounts/[id]/block.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/follow": import(
|
"/api/v1/accounts/[id]/follow": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/follow.ts"
|
serverPath + "/api/v1/accounts/[id]/follow.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/followers": import(
|
"/api/v1/accounts/[id]/followers": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/followers.ts"
|
serverPath + "/api/v1/accounts/[id]/followers.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/following": import(
|
"/api/v1/accounts/[id]/following": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/following.ts"
|
serverPath + "/api/v1/accounts/[id]/following.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/mute": import(
|
"/api/v1/accounts/[id]/mute": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/mute.ts"
|
serverPath + "/api/v1/accounts/[id]/mute.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/note": import(
|
"/api/v1/accounts/[id]/note": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/note.ts"
|
serverPath + "/api/v1/accounts/[id]/note.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/pin": import(
|
"/api/v1/accounts/[id]/pin": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/pin.ts"
|
serverPath + "/api/v1/accounts/[id]/pin.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/remove_from_followers": import(
|
"/api/v1/accounts/[id]/remove_from_followers": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts"
|
serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/statuses": import(
|
"/api/v1/accounts/[id]/statuses": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/statuses.ts"
|
serverPath + "/api/v1/accounts/[id]/statuses.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/unblock": import(
|
"/api/v1/accounts/[id]/unblock": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/unblock.ts"
|
serverPath + "/api/v1/accounts/[id]/unblock.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/unfollow": import(
|
"/api/v1/accounts/[id]/unfollow": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/unfollow.ts"
|
serverPath + "/api/v1/accounts/[id]/unfollow.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/unmute": import(
|
"/api/v1/accounts/[id]/unmute": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/unmute.ts"
|
serverPath + "/api/v1/accounts/[id]/unmute.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/accounts/[id]/unpin": import(
|
"/api/v1/accounts/[id]/unpin": await import(
|
||||||
serverPath + "/api/v1/accounts/[id]/unpin.ts"
|
serverPath + "/api/v1/accounts/[id]/unpin.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/follow_requests/[account_id]/authorize": import(
|
"/api/v1/follow_requests/[account_id]/authorize": await import(
|
||||||
serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts"
|
serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/follow_requests/[account_id]/reject": import(
|
"/api/v1/follow_requests/[account_id]/reject": await import(
|
||||||
serverPath + "/api/v1/follow_requests/[account_id]/reject.ts"
|
serverPath + "/api/v1/follow_requests/[account_id]/reject.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/media/[id]": import(serverPath + "/api/v1/media/[id]/index.ts"),
|
"/api/v1/media/[id]": await import(
|
||||||
"/api/v1/statuses/[id]": import(
|
serverPath + "/api/v1/media/[id]/index.ts"
|
||||||
|
),
|
||||||
|
"/api/v1/statuses/[id]": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/index.ts"
|
serverPath + "/api/v1/statuses/[id]/index.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/context": import(
|
"/api/v1/statuses/[id]/context": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/context.ts"
|
serverPath + "/api/v1/statuses/[id]/context.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/favourite": import(
|
"/api/v1/statuses/[id]/favourite": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/favourite.ts"
|
serverPath + "/api/v1/statuses/[id]/favourite.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/favourited_by": import(
|
"/api/v1/statuses/[id]/favourited_by": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/favourited_by.ts"
|
serverPath + "/api/v1/statuses/[id]/favourited_by.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/pin": import(
|
"/api/v1/statuses/[id]/pin": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/pin.ts"
|
serverPath + "/api/v1/statuses/[id]/pin.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/reblog": import(
|
"/api/v1/statuses/[id]/reblog": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/reblog.ts"
|
serverPath + "/api/v1/statuses/[id]/reblog.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/reblogged_by": import(
|
"/api/v1/statuses/[id]/reblogged_by": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/reblogged_by.ts"
|
serverPath + "/api/v1/statuses/[id]/reblogged_by.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/source": import(
|
"/api/v1/statuses/[id]/source": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/source.ts"
|
serverPath + "/api/v1/statuses/[id]/source.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/unfavourite": import(
|
"/api/v1/statuses/[id]/unfavourite": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/unfavourite.ts"
|
serverPath + "/api/v1/statuses/[id]/unfavourite.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/unpin": import(
|
"/api/v1/statuses/[id]/unpin": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/unpin.ts"
|
serverPath + "/api/v1/statuses/[id]/unpin.ts"
|
||||||
),
|
),
|
||||||
"/api/v1/statuses/[id]/unreblog": import(
|
"/api/v1/statuses/[id]/unreblog": await import(
|
||||||
serverPath + "/api/v1/statuses/[id]/unreblog.ts"
|
serverPath + "/api/v1/statuses/[id]/unreblog.ts"
|
||||||
),
|
),
|
||||||
"/media/[id]": import(serverPath + "/media/[id]/index.ts"),
|
"/media/[id]": await import(serverPath + "/media/[id]/index.ts"),
|
||||||
"/oauth/callback/[issuer]": import(
|
"/oauth/callback/[issuer]": await import(
|
||||||
serverPath + "/oauth/callback/[issuer]/index.ts"
|
serverPath + "/oauth/callback/[issuer]/index.ts"
|
||||||
),
|
),
|
||||||
"/object/[uuid]": import(serverPath + "/object/[uuid]/index.ts"),
|
"/object/[uuid]": await import(serverPath + "/object/[uuid]/index.ts"),
|
||||||
"/users/[uuid]": import(serverPath + "/users/[uuid]/index.ts"),
|
"/users/[uuid]": await import(serverPath + "/users/[uuid]/index.ts"),
|
||||||
"/users/[uuid]/inbox": import(serverPath + "/users/[uuid]/inbox/index.ts"),
|
"/users/[uuid]/inbox": await import(
|
||||||
"/users/[uuid]/outbox": import(
|
serverPath + "/users/[uuid]/inbox/index.ts"
|
||||||
|
),
|
||||||
|
"/users/[uuid]/outbox": await import(
|
||||||
serverPath + "/users/[uuid]/outbox/index.ts"
|
serverPath + "/users/[uuid]/outbox/index.ts"
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -158,7 +169,7 @@ export const routeMatcher = new Bun.FileSystemRouter({
|
||||||
dir: process.cwd() + "/server/api",
|
dir: process.cwd() + "/server/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const matchRoute = (url: string) => {
|
export const matchRoute = <T = Record<string, never>>(url: string) => {
|
||||||
const route = routeMatcher.match(url);
|
const route = routeMatcher.match(url);
|
||||||
if (!route) return { file: null, matchedRoute: null };
|
if (!route) return { file: null, matchedRoute: null };
|
||||||
|
|
||||||
|
|
@ -166,11 +177,7 @@ export const matchRoute = (url: string) => {
|
||||||
// @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file
|
// @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file
|
||||||
file: rawRoutes[route.name] as Promise<{
|
file: rawRoutes[route.name] as Promise<{
|
||||||
meta: APIRouteMeta;
|
meta: APIRouteMeta;
|
||||||
default: (
|
default: RouteHandler<T>;
|
||||||
req: Request,
|
|
||||||
matchedRoute: MatchedRoute,
|
|
||||||
auth: AuthData
|
|
||||||
) => Response | Promise<Response>;
|
|
||||||
}>,
|
}>,
|
||||||
matchedRoute: route,
|
matchedRoute: route,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
157
server.ts
Normal file
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)
|
* Find familiar followers (followers of a user that you also follow)
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<{
|
||||||
"id[]": string[];
|
id: string[];
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { "id[]": ids } = extraData.parsedRequest;
|
const { id: ids } = extraData.parsedRequest;
|
||||||
|
|
||||||
// Minimum id count 1, maximum 10
|
// Minimum id count 1, maximum 10
|
||||||
if (!ids || ids.length < 1 || ids.length > 10) {
|
if (!ids || ids.length < 1 || ids.length > 10) {
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@ export const meta = applyConfig({
|
||||||
* Find relationships
|
* Find relationships
|
||||||
*/
|
*/
|
||||||
export default apiRoute<{
|
export default apiRoute<{
|
||||||
"id[]": string[];
|
id: string[];
|
||||||
}>(async (req, matchedRoute, extraData) => {
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
const { user: self } = extraData.auth;
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
const { "id[]": ids } = extraData.parsedRequest;
|
const { id: ids } = extraData.parsedRequest;
|
||||||
|
|
||||||
// Minimum id count 1, maximum 10
|
// Minimum id count 1, maximum 10
|
||||||
if (!ids || ids.length < 1 || ids.length > 10) {
|
if (!ids || ids.length < 1 || ids.length > 10) {
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ export default apiRoute<{
|
||||||
sensitive?: boolean;
|
sensitive?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
content_type?: string;
|
content_type?: string;
|
||||||
"media_ids[]"?: string[];
|
media_ids?: string[];
|
||||||
"poll[options][]"?: string[];
|
"poll[options]"?: string[];
|
||||||
"poll[expires_in]"?: number;
|
"poll[expires_in]"?: number;
|
||||||
"poll[multiple]"?: boolean;
|
"poll[multiple]"?: boolean;
|
||||||
"poll[hide_totals]"?: boolean;
|
"poll[hide_totals]"?: boolean;
|
||||||
|
|
@ -88,8 +88,8 @@ export default apiRoute<{
|
||||||
status: statusText,
|
status: statusText,
|
||||||
content_type,
|
content_type,
|
||||||
"poll[expires_in]": expires_in,
|
"poll[expires_in]": expires_in,
|
||||||
"poll[options][]": options,
|
"poll[options]": options,
|
||||||
"media_ids[]": media_ids,
|
media_ids: media_ids,
|
||||||
spoiler_text,
|
spoiler_text,
|
||||||
sensitive,
|
sensitive,
|
||||||
} = extraData.parsedRequest;
|
} = extraData.parsedRequest;
|
||||||
|
|
|
||||||
|
|
@ -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 type { Token } from "@prisma/client";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import { TokenType } from "~database/entities/Token";
|
import { TokenType } from "~database/entities/Token";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,8 +9,10 @@ import {
|
||||||
} from "~database/entities/User";
|
} from "~database/entities/User";
|
||||||
import type { APIEmoji } from "~types/entities/emoji";
|
import type { APIEmoji } from "~types/entities/emoji";
|
||||||
import type { APIInstance } from "~types/entities/instance";
|
import type { APIInstance } from "~types/entities/instance";
|
||||||
|
import { sendTestRequest, wrapRelativeUrl } from "./utils";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
const base_url = config.http.base_url;
|
||||||
|
|
||||||
let token: Token;
|
let token: Token;
|
||||||
let user: UserWithRelations;
|
let user: UserWithRelations;
|
||||||
|
|
@ -71,14 +71,16 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/instance", () => {
|
describe("GET /api/v1/instance", () => {
|
||||||
test("should return an APIInstance object", async () => {
|
test("should return an APIInstance object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/instance`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(`${base_url}/api/v1/instance`, base_url),
|
||||||
method: "GET",
|
{
|
||||||
headers: {
|
method: "GET",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -117,15 +119,21 @@ describe("API Tests", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return an array of at least one custom emoji", async () => {
|
test("should return an array of at least one custom emoji", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/custom_emojis`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`${base_url}/api/v1/custom_emojis`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -139,6 +147,7 @@ describe("API Tests", () => {
|
||||||
expect(emojis[0].shortcode).toBeString();
|
expect(emojis[0].shortcode).toBeString();
|
||||||
expect(emojis[0].url).toBeString();
|
expect(emojis[0].url).toBeString();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await client.emoji.deleteMany({
|
await client.emoji.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { getConfig } from "~classes/configmanager";
|
|
||||||
import type { Token } from "@prisma/client";
|
import type { Token } from "@prisma/client";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
|
|
@ -12,21 +9,24 @@ import {
|
||||||
import type { APIAccount } from "~types/entities/account";
|
import type { APIAccount } from "~types/entities/account";
|
||||||
import type { APIRelationship } from "~types/entities/relationship";
|
import type { APIRelationship } from "~types/entities/relationship";
|
||||||
import type { APIStatus } from "~types/entities/status";
|
import type { APIStatus } from "~types/entities/status";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
import { sendTestRequest, wrapRelativeUrl } from "~tests/utils";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
const base_url = config.http.base_url;
|
||||||
|
|
||||||
let token: Token;
|
let token: Token;
|
||||||
let user: UserWithRelations;
|
let user: UserWithRelations;
|
||||||
let user2: UserWithRelations;
|
let user2: UserWithRelations;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
/* await client.user.deleteMany({
|
await client.user.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
username: {
|
username: {
|
||||||
in: ["test", "test2"],
|
in: ["test", "test2"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}); */
|
});
|
||||||
|
|
||||||
user = await createNewLocalUser({
|
user = await createNewLocalUser({
|
||||||
email: "test@test.com",
|
email: "test@test.com",
|
||||||
|
|
@ -87,15 +87,17 @@ afterAll(async () => {
|
||||||
describe("API Tests", () => {
|
describe("API Tests", () => {
|
||||||
describe("POST /api/v1/accounts/:id", () => {
|
describe("POST /api/v1/accounts/:id", () => {
|
||||||
test("should return a 404 error when trying to fetch a non-existent user", async () => {
|
test("should return a 404 error when trying to fetch a non-existent user", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/999999`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl("/api/v1/accounts/999999", base_url),
|
||||||
method: "GET",
|
{
|
||||||
headers: {
|
method: "GET",
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
|
|
@ -107,18 +109,23 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("PATCH /api/v1/accounts/update_credentials", () => {
|
describe("PATCH /api/v1/accounts/update_credentials", () => {
|
||||||
test("should update the authenticated user's display name", async () => {
|
test("should update the authenticated user's display name", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/update_credentials`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "PATCH",
|
"/api/v1/accounts/update_credentials",
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
headers: {
|
||||||
display_name: "New Display Name",
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
}),
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
display_name: "New Display Name",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -134,15 +141,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/accounts/verify_credentials", () => {
|
describe("GET /api/v1/accounts/verify_credentials", () => {
|
||||||
test("should return the authenticated user's account information", async () => {
|
test("should return the authenticated user's account information", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/verify_credentials`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
"/api/v1/accounts/verify_credentials",
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "GET",
|
||||||
}
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -179,15 +191,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/accounts/:id/statuses", () => {
|
describe("GET /api/v1/accounts/:id/statuses", () => {
|
||||||
test("should return the statuses of the specified user", async () => {
|
test("should return the statuses of the specified user", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user.id}/statuses`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`/api/v1/accounts/${user.id}/statuses`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "GET",
|
||||||
}
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -203,16 +220,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/follow", () => {
|
describe("POST /api/v1/accounts/:id/follow", () => {
|
||||||
test("should follow the specified user and return an APIRelationship object", async () => {
|
test("should follow the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/follow`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/follow`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -229,16 +251,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/unfollow", () => {
|
describe("POST /api/v1/accounts/:id/unfollow", () => {
|
||||||
test("should unfollow the specified user and return an APIRelationship object", async () => {
|
test("should unfollow the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/unfollow`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/unfollow`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -255,16 +282,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/remove_from_followers", () => {
|
describe("POST /api/v1/accounts/:id/remove_from_followers", () => {
|
||||||
test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => {
|
test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/remove_from_followers`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/remove_from_followers`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -281,16 +313,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/block", () => {
|
describe("POST /api/v1/accounts/:id/block", () => {
|
||||||
test("should block the specified user and return an APIRelationship object", async () => {
|
test("should block the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/block`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/block`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -307,14 +344,13 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/blocks", () => {
|
describe("GET /api/v1/blocks", () => {
|
||||||
test("should return an array of APIAccount objects for the user's blocked accounts", async () => {
|
test("should return an array of APIAccount objects for the user's blocked accounts", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/blocks`,
|
new Request(wrapRelativeUrl("/api/v1/blocks", base_url), {
|
||||||
{
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -331,16 +367,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/unblock", () => {
|
describe("POST /api/v1/accounts/:id/unblock", () => {
|
||||||
test("should unblock the specified user and return an APIRelationship object", async () => {
|
test("should unblock the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/unblock`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/unblock`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -357,16 +398,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => {
|
describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => {
|
||||||
test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => {
|
test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/mute`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/mute`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({ notifications: true }),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ notifications: true }),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 () => {
|
test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/mute`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/mute`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({ notifications: false }),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ notifications: false }),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -409,14 +460,13 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/mutes", () => {
|
describe("GET /api/v1/mutes", () => {
|
||||||
test("should return an array of APIAccount objects for the user's muted accounts", async () => {
|
test("should return an array of APIAccount objects for the user's muted accounts", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/mutes`,
|
new Request(wrapRelativeUrl("/api/v1/mutes", base_url), {
|
||||||
{
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -434,16 +484,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/unmute", () => {
|
describe("POST /api/v1/accounts/:id/unmute", () => {
|
||||||
test("should unmute the specified user and return an APIRelationship object", async () => {
|
test("should unmute the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/unmute`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/unmute`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -460,16 +515,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/pin", () => {
|
describe("POST /api/v1/accounts/:id/pin", () => {
|
||||||
test("should pin the specified user and return an APIRelationship object", async () => {
|
test("should pin the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/pin`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/pin`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -486,16 +546,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/unpin", () => {
|
describe("POST /api/v1/accounts/:id/unpin", () => {
|
||||||
test("should unpin the specified user and return an APIRelationship object", async () => {
|
test("should unpin the specified user and return an APIRelationship object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/unpin`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/unpin`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -512,16 +577,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/accounts/:id/note", () => {
|
describe("POST /api/v1/accounts/:id/note", () => {
|
||||||
test("should update the specified account's note and return the updated account object", async () => {
|
test("should update the specified account's note and return the updated account object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/note`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/note`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({ comment: "This is a new note" }),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ comment: "This is a new note" }),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -538,14 +608,19 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/accounts/relationships", () => {
|
describe("GET /api/v1/accounts/relationships", () => {
|
||||||
test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => {
|
test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/relationships?id[]=${user2.id}`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`/api/v1/accounts/relationships?id[]=${user2.id}`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -571,15 +646,17 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("DELETE /api/v1/profile/avatar", () => {
|
describe("DELETE /api/v1/profile/avatar", () => {
|
||||||
test("should delete the avatar of the authenticated user and return the updated account object", async () => {
|
test("should delete the avatar of the authenticated user and return the updated account object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/profile/avatar`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl("/api/v1/profile/avatar", base_url),
|
||||||
method: "DELETE",
|
{
|
||||||
headers: {
|
method: "DELETE",
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -596,15 +673,17 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("DELETE /api/v1/profile/header", () => {
|
describe("DELETE /api/v1/profile/header", () => {
|
||||||
test("should delete the header of the authenticated user and return the updated account object", async () => {
|
test("should delete the header of the authenticated user and return the updated account object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/profile/header`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl("/api/v1/profile/header", base_url),
|
||||||
method: "DELETE",
|
{
|
||||||
headers: {
|
method: "DELETE",
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -621,16 +700,21 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/accounts/familiar_followers", () => {
|
describe("GET /api/v1/accounts/familiar_followers", () => {
|
||||||
test("should follow the user", async () => {
|
test("should follow the user", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user2.id}/follow`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/api/v1/accounts/${user2.id}/follow`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
headers: {
|
||||||
}
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -640,14 +724,19 @@ describe("API Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => {
|
test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/familiar_followers?id[]=${user2.id}`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`/api/v1/accounts/familiar_followers?id[]=${user2.id}`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 type { Token } from "@prisma/client";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
|
|
@ -13,8 +10,11 @@ import type { APIAccount } from "~types/entities/account";
|
||||||
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
|
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
|
||||||
import type { APIContext } from "~types/entities/context";
|
import type { APIContext } from "~types/entities/context";
|
||||||
import type { APIStatus } from "~types/entities/status";
|
import type { APIStatus } from "~types/entities/status";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
import { sendTestRequest, wrapRelativeUrl } from "~tests/utils";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
const base_url = config.http.base_url;
|
||||||
|
|
||||||
let token: Token;
|
let token: Token;
|
||||||
let user: UserWithRelations;
|
let user: UserWithRelations;
|
||||||
|
|
@ -86,15 +86,17 @@ describe("API Tests", () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", new Blob(["test"], { type: "text/plain" }));
|
formData.append("file", new Blob(["test"], { type: "text/plain" }));
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v2/media`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(`${base_url}/api/v2/media`, base_url),
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
body: formData,
|
},
|
||||||
}
|
body: formData,
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(202);
|
expect(response.status).toBe(202);
|
||||||
|
|
@ -112,20 +114,22 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/statuses", () => {
|
describe("POST /api/v1/statuses", () => {
|
||||||
test("should create a new status and return an APIStatus object", async () => {
|
test("should create a new status and return an APIStatus object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
status: "Hello, world!",
|
body: JSON.stringify({
|
||||||
visibility: "public",
|
status: "Hello, world!",
|
||||||
media_ids: [media1?.id],
|
visibility: "public",
|
||||||
}),
|
media_ids: [media1?.id],
|
||||||
}
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 () => {
|
test("should create a new status in reply to the previous one", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(`${base_url}/api/v1/statuses`, base_url),
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
status: "This is a reply!",
|
body: JSON.stringify({
|
||||||
visibility: "public",
|
status: "This is a reply!",
|
||||||
in_reply_to_id: status?.id,
|
visibility: "public",
|
||||||
}),
|
in_reply_to_id: status?.id,
|
||||||
}
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -206,14 +212,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/statuses/:id", () => {
|
describe("GET /api/v1/statuses/:id", () => {
|
||||||
test("should return the specified status object", async () => {
|
test("should return the specified status object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`${base_url}/api/v1/statuses/${status?.id}`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -251,15 +263,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/statuses/:id/reblog", () => {
|
describe("POST /api/v1/statuses/:id/reblog", () => {
|
||||||
test("should reblog the specified status and return the reblogged status object", async () => {
|
test("should reblog the specified status and return the reblogged status object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}/reblog`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`${base_url}/api/v1/statuses/${status?.id}/reblog`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
}
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -277,15 +294,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/statuses/:id/unreblog", () => {
|
describe("POST /api/v1/statuses/:id/unreblog", () => {
|
||||||
test("should unreblog the specified status and return the original status object", async () => {
|
test("should unreblog the specified status and return the original status object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}/unreblog`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`${base_url}/api/v1/statuses/${status?.id}/unreblog`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "POST",
|
||||||
}
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -302,15 +324,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/statuses/:id/context", () => {
|
describe("GET /api/v1/statuses/:id/context", () => {
|
||||||
test("should return the context of the specified status", async () => {
|
test("should return the context of the specified status", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}/context`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`${base_url}/api/v1/statuses/${status?.id}/context`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "GET",
|
||||||
}
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -330,14 +357,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/timelines/public", () => {
|
describe("GET /api/v1/timelines/public", () => {
|
||||||
test("should return an array of APIStatus objects that includes the created status", async () => {
|
test("should return an array of APIStatus objects that includes the created status", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/timelines/public`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`${base_url}/api/v1/timelines/public`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -353,15 +386,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/accounts/:id/statuses", () => {
|
describe("GET /api/v1/accounts/:id/statuses", () => {
|
||||||
test("should return the statuses of the specified user", async () => {
|
test("should return the statuses of the specified user", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/accounts/${user.id}/statuses`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`${base_url}/api/v1/accounts/${user.id}/statuses`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
method: "GET",
|
||||||
}
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -384,14 +422,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("POST /api/v1/statuses/:id/favourite", () => {
|
describe("POST /api/v1/statuses/:id/favourite", () => {
|
||||||
test("should favourite the specified status object", async () => {
|
test("should favourite the specified status object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}/favourite`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`${base_url}/api/v1/statuses/${status?.id}/favourite`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -400,14 +444,20 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/statuses/:id/favourited_by", () => {
|
describe("GET /api/v1/statuses/:id/favourited_by", () => {
|
||||||
test("should return an array of User objects who favourited the specified status", async () => {
|
test("should return an array of User objects who favourited the specified status", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}/favourited_by`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "GET",
|
`${base_url}/api/v1/statuses/${status?.id}/favourited_by`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -425,14 +475,20 @@ describe("API Tests", () => {
|
||||||
describe("POST /api/v1/statuses/:id/unfavourite", () => {
|
describe("POST /api/v1/statuses/:id/unfavourite", () => {
|
||||||
test("should unfavourite the specified status object", async () => {
|
test("should unfavourite the specified status object", async () => {
|
||||||
// Unfavourite the status
|
// Unfavourite the status
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}/unfavourite`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`${base_url}/api/v1/statuses/${status?.id}/unfavourite`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
@ -449,14 +505,19 @@ describe("API Tests", () => {
|
||||||
|
|
||||||
describe("DELETE /api/v1/statuses/:id", () => {
|
describe("DELETE /api/v1/statuses/:id", () => {
|
||||||
test("should delete the specified status object", async () => {
|
test("should delete the specified status object", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/statuses/${status?.id}`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "DELETE",
|
`${base_url}/api/v1/statuses/${status?.id}`,
|
||||||
headers: {
|
base_url
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
),
|
||||||
},
|
{
|
||||||
}
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 type { Application, Token } from "@prisma/client";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import { createNewLocalUser } from "~database/entities/User";
|
import { createNewLocalUser } from "~database/entities/User";
|
||||||
|
import { sendTestRequest, wrapRelativeUrl } from "./utils";
|
||||||
|
|
||||||
const config = getConfig();
|
// const config = await new ConfigManager({}).getConfig();
|
||||||
|
const base_url = "http://lysand.localhost:8080"; //config.http.base_url;
|
||||||
|
|
||||||
let client_id: string;
|
let client_id: string;
|
||||||
let client_secret: string;
|
let client_secret: string;
|
||||||
|
|
@ -30,10 +31,12 @@ describe("POST /api/v1/apps/", () => {
|
||||||
formData.append("redirect_uris", "https://example.com");
|
formData.append("redirect_uris", "https://example.com");
|
||||||
formData.append("scopes", "read write");
|
formData.append("scopes", "read write");
|
||||||
|
|
||||||
const response = await fetch(`${config.http.base_url}/api/v1/apps/`, {
|
const response = await sendTestRequest(
|
||||||
method: "POST",
|
new Request(wrapRelativeUrl("/api/v1/apps/", base_url), {
|
||||||
body: formData,
|
method: "POST",
|
||||||
});
|
body: formData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers.get("content-type")).toBe("application/json");
|
expect(response.headers.get("content-type")).toBe("application/json");
|
||||||
|
|
@ -65,14 +68,19 @@ describe("POST /auth/login/", () => {
|
||||||
formData.append("email", "test@test.com");
|
formData.append("email", "test@test.com");
|
||||||
formData.append("password", "test");
|
formData.append("password", "test");
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl(
|
||||||
method: "POST",
|
`/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||||
body: formData,
|
base_url
|
||||||
redirect: "manual",
|
),
|
||||||
}
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
expect(response.status).toBe(302);
|
||||||
expect(response.headers.get("Location")).toMatch(
|
expect(response.headers.get("Location")).toMatch(
|
||||||
/https:\/\/example.com\?code=/
|
/https:\/\/example.com\?code=/
|
||||||
|
|
@ -94,11 +102,12 @@ describe("POST /oauth/token/", () => {
|
||||||
formData.append("client_secret", client_secret);
|
formData.append("client_secret", client_secret);
|
||||||
formData.append("scope", "read+write");
|
formData.append("scope", "read+write");
|
||||||
|
|
||||||
const response = await fetch(`${config.http.base_url}/oauth/token/`, {
|
const response = await sendTestRequest(
|
||||||
method: "POST",
|
new Request(wrapRelativeUrl("/oauth/token/", base_url), {
|
||||||
// Do not set the Content-Type header for some reason
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
|
@ -119,15 +128,15 @@ describe("POST /oauth/token/", () => {
|
||||||
|
|
||||||
describe("GET /api/v1/apps/verify_credentials", () => {
|
describe("GET /api/v1/apps/verify_credentials", () => {
|
||||||
test("should return the authenticated application's credentials", async () => {
|
test("should return the authenticated application's credentials", async () => {
|
||||||
const response = await fetch(
|
const response = await sendTestRequest(
|
||||||
`${config.http.base_url}/api/v1/apps/verify_credentials`,
|
new Request(
|
||||||
{
|
wrapRelativeUrl("/api/v1/apps/verify_credentials", base_url),
|
||||||
method: "GET",
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.access_token}`,
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
},
|
}
|
||||||
}
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
|
||||||
15
tests/utils.ts
Normal file
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
|
content_type?: string
|
||||||
) => {
|
) => {
|
||||||
if (content_type === "text/markdown") {
|
if (content_type === "text/markdown") {
|
||||||
return linkifyHtml(await sanitizeHtml(parse(text)));
|
return linkifyHtml(await sanitizeHtml(await parse(text)));
|
||||||
} else if (content_type === "text/x.misskeymarkdown") {
|
} else if (content_type === "text/x.misskeymarkdown") {
|
||||||
// Parse as MFM
|
// Parse as MFM
|
||||||
// TODO: Implement MFM
|
// TODO: Implement MFM
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import { getConfig } from "~classes/configmanager";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import { Meilisearch } from "meilisearch";
|
import { Meilisearch } from "meilisearch";
|
||||||
import type { Status, User } from "@prisma/client";
|
import type { Status, User } from "@prisma/client";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
|
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
export const meilisearch = new Meilisearch({
|
export const meilisearch = new Meilisearch({
|
||||||
host: `${config.meilisearch.host}:${config.meilisearch.port}`,
|
host: `${config.meilisearch.host}:${config.meilisearch.port}`,
|
||||||
apiKey: config.meilisearch.api_key,
|
apiKey: config.meilisearch.api_key,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const connectMeili = async () => {
|
export const connectMeili = async (logger: MultiLogManager | LogManager) => {
|
||||||
if (!config.meilisearch.enabled) return;
|
if (!config.meilisearch.enabled) return;
|
||||||
|
|
||||||
if (await meilisearch.isHealthy()) {
|
if (await meilisearch.isHealthy()) {
|
||||||
|
|
@ -31,14 +32,16 @@ export const connectMeili = async () => {
|
||||||
.index(MeiliIndexType.Statuses)
|
.index(MeiliIndexType.Statuses)
|
||||||
.updateSearchableAttributes(["content"]);
|
.updateSearchableAttributes(["content"]);
|
||||||
|
|
||||||
console.log(
|
await logger.log(
|
||||||
`${chalk.green(`✓`)} ${chalk.bold(`Connected to Meilisearch`)}`
|
LogLevel.INFO,
|
||||||
|
"Meilisearch",
|
||||||
|
"Connected to Meilisearch"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
await logger.log(
|
||||||
`${chalk.red(`✗`)} ${chalk.bold(
|
LogLevel.CRITICAL,
|
||||||
`Error while connecting to Meilisearch`
|
"Meilisearch",
|
||||||
)}`
|
"Error while connecting to Meilisearch"
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
utils/module.ts
Normal file
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 type { Prisma } from "@prisma/client";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { ConfigManager } from "config-manager";
|
||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { createPrismaRedisCache } from "prisma-redis-middleware";
|
import { createPrismaRedisCache } from "prisma-redis-middleware";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
const cacheRedis = config.redis.cache.enabled
|
const cacheRedis = config.redis.cache.enabled
|
||||||
? new Redis({
|
? new Redis({
|
||||||
|
|
@ -12,7 +12,7 @@ const cacheRedis = config.redis.cache.enabled
|
||||||
port: Number(config.redis.cache.port),
|
port: Number(config.redis.cache.port),
|
||||||
password: config.redis.cache.password,
|
password: config.redis.cache.password,
|
||||||
db: Number(config.redis.cache.database ?? 0),
|
db: Number(config.redis.cache.database ?? 0),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
cacheRedis?.on("error", e => {
|
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";
|
import { sanitize } from "isomorphic-dompurify";
|
||||||
|
|
||||||
export const sanitizeHtml = async (html: string) => {
|
export const sanitizeHtml = async (html: string) => {
|
||||||
const config = getConfig();
|
const config = await new ConfigManager({}).getConfig();
|
||||||
|
|
||||||
const sanitizedHtml = sanitize(html, {
|
const sanitizedHtml = sanitize(html, {
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue